CustomScheduleService.php 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132
  1. <?php
  2. namespace App\Services;
  3. use App\Broadcasting\RealtimeEvent;
  4. use App\Broadcasting\RealtimeRoom;
  5. use App\Broadcasting\RealtimeService;
  6. use App\Enums\ApprovalStatusEnum;
  7. use App\Enums\NotificationTypeEnum;
  8. use App\Jobs\NotifyProvidersOfNewOpportunityJob;
  9. use App\Models\Address;
  10. use App\Models\CustomSchedule;
  11. use App\Models\CustomScheduleSpeciality;
  12. use App\Models\Provider;
  13. use App\Models\Schedule;
  14. use App\Models\ScheduleProposal;
  15. use App\Models\ScheduleRefuse;
  16. use App\Models\ServicePackage;
  17. use App\Notifications\Push\Cliente\Agendamento\PrestadorAceitouPush;
  18. use App\Notifications\Push\Cliente\Agendamento\PrestadorRecusouPush;
  19. use App\Notifications\Push\Prestador\Agendamento\ClienteRecusouPush;
  20. use App\Notifications\Push\Prestador\Agendamento\ClienteAceitouPush;
  21. use App\Services\PushNotificationService;
  22. use App\Rules\ScheduleBusinessRules;
  23. use App\Services\NotificationService;
  24. use App\Services\DistanceService;
  25. use Carbon\Carbon;
  26. use Illuminate\Support\Collection;
  27. use Illuminate\Support\Facades\DB;
  28. use Illuminate\Support\Facades\Log;
  29. use Illuminate\Support\Facades\Storage;
  30. class CustomScheduleService
  31. {
  32. private const NEARBY_RADIUS_KM = 20.0;
  33. public function __construct(
  34. private readonly ZipCodeCoordinatesService $zipCodeCoordinatesService,
  35. private readonly RealtimeService $realtime,
  36. ) {}
  37. public function getAll()
  38. {
  39. $custom_schedules = CustomSchedule::with([
  40. 'schedule.client.user',
  41. 'schedule.address',
  42. 'serviceType',
  43. 'specialities.speciality',
  44. ])
  45. ->orderBy('id', 'desc')
  46. ->get();
  47. return $custom_schedules;
  48. }
  49. public function getById($id)
  50. {
  51. $customSchedule = CustomSchedule::with([
  52. 'schedule.client.user',
  53. 'schedule.client.profileMedia',
  54. 'schedule.address',
  55. 'serviceType',
  56. 'specialities.speciality',
  57. ])->find($id);
  58. return $customSchedule;
  59. }
  60. public function create(array $data)
  61. {
  62. $address = Address::find(data_get($data, 'address_id'));
  63. if (! $address || $address->latitude === null || $address->longitude === null) {
  64. throw new \Exception(__('messages.client_address_location_required'));
  65. }
  66. DB::beginTransaction();
  67. try {
  68. $quantity = data_get($data, 'quantity', 1);
  69. $specialityIds = data_get($data, 'speciality_ids', []);
  70. $createdCustomSchedules = [];
  71. for ($i = 0; $i < $quantity; $i++) {
  72. $scheduleData = [
  73. 'client_id' => data_get($data, 'client_id'),
  74. 'provider_id' => null,
  75. 'address_id' => data_get($data, 'address_id'),
  76. 'date' => data_get($data, 'date'),
  77. 'period_type' => data_get($data, 'period_type'),
  78. 'schedule_type' => 'custom',
  79. 'start_time' => data_get($data, 'start_time'),
  80. 'end_time' => data_get($data, 'end_time'),
  81. 'status' => 'pending',
  82. 'total_amount' => 0,
  83. 'code' => str_pad(random_int(0, 9999), 4, '0', STR_PAD_LEFT),
  84. 'code_verified' => false,
  85. ];
  86. $schedule = Schedule::create($scheduleData);
  87. $customScheduleData = [
  88. 'schedule_id' => $schedule->id,
  89. 'address_type' => data_get($data, 'address_type'),
  90. 'service_type_id' => data_get($data, 'service_type_id'),
  91. 'description' => data_get($data, 'description'),
  92. 'min_price' => data_get($data, 'min_price'),
  93. 'max_price' => data_get($data, 'max_price'),
  94. 'offers_meal' => data_get($data, 'offers_meal', false),
  95. ];
  96. $customSchedule = CustomSchedule::create($customScheduleData);
  97. if (! empty($specialityIds)) {
  98. foreach ($specialityIds as $specialityId) {
  99. CustomScheduleSpeciality::create([
  100. 'custom_schedule_id' => $customSchedule->id,
  101. 'speciality_id' => $specialityId,
  102. ]);
  103. }
  104. }
  105. $createdCustomSchedules[] = $customSchedule->load([
  106. 'schedule.client.user',
  107. 'schedule.address',
  108. 'serviceType',
  109. 'specialities.speciality',
  110. ]);
  111. }
  112. DB::commit();
  113. $this->dispatchOpportunityNotification($createdCustomSchedules);
  114. return $createdCustomSchedules;
  115. } catch (\Exception $e) {
  116. DB::rollBack();
  117. Log::error('Erro ao criar agendamento personalizado: ' . $e->getMessage());
  118. throw $e;
  119. }
  120. }
  121. public function update($id, array $data)
  122. {
  123. DB::beginTransaction();
  124. try {
  125. $customSchedule = CustomSchedule::findOrFail($id);
  126. $schedule = $customSchedule->schedule;
  127. $scheduleUpdateData = [];
  128. if (data_get($data, 'address_id') !== null) {
  129. $scheduleUpdateData['address_id'] = data_get($data, 'address_id');
  130. }
  131. if (data_get($data, 'date') !== null) {
  132. $scheduleUpdateData['date'] = data_get($data, 'date');
  133. }
  134. if (data_get($data, 'period_type') !== null) {
  135. $scheduleUpdateData['period_type'] = data_get($data, 'period_type');
  136. }
  137. if (data_get($data, 'start_time') !== null) {
  138. $scheduleUpdateData['start_time'] = data_get($data, 'start_time');
  139. }
  140. if (data_get($data, 'end_time') !== null) {
  141. $scheduleUpdateData['end_time'] = data_get($data, 'end_time');
  142. }
  143. if (! empty($scheduleUpdateData)) {
  144. $schedule->update($scheduleUpdateData);
  145. }
  146. $customScheduleUpdateData = [];
  147. if (data_get($data, 'address_type') !== null) {
  148. $customScheduleUpdateData['address_type'] = data_get($data, 'address_type');
  149. }
  150. if (data_get($data, 'service_type_id') !== null) {
  151. $customScheduleUpdateData['service_type_id'] = data_get($data, 'service_type_id');
  152. }
  153. if (data_get($data, 'description') !== null) {
  154. $customScheduleUpdateData['description'] = data_get($data, 'description');
  155. }
  156. if (data_get($data, 'min_price') !== null) {
  157. $customScheduleUpdateData['min_price'] = data_get($data, 'min_price');
  158. }
  159. if (data_get($data, 'max_price') !== null) {
  160. $customScheduleUpdateData['max_price'] = data_get($data, 'max_price');
  161. }
  162. if (data_get($data, 'offers_meal') !== null) {
  163. $customScheduleUpdateData['offers_meal'] = data_get($data, 'offers_meal');
  164. }
  165. if (! empty($customScheduleUpdateData)) {
  166. $customSchedule->update($customScheduleUpdateData);
  167. }
  168. if (data_get($data, 'speciality_ids') !== null) {
  169. $custom_schedule = CustomScheduleSpeciality::where('custom_schedule_id', $customSchedule->id);
  170. $custom_schedule->delete();
  171. foreach (data_get($data, 'speciality_ids') as $specialityId) {
  172. CustomScheduleSpeciality::create([
  173. 'custom_schedule_id' => $customSchedule->id,
  174. 'speciality_id' => $specialityId,
  175. ]);
  176. }
  177. }
  178. DB::commit();
  179. return $customSchedule->fresh([
  180. 'schedule.client.user',
  181. 'schedule.address',
  182. 'serviceType',
  183. 'specialities.speciality',
  184. ]);
  185. } catch (\Exception $e) {
  186. DB::rollBack();
  187. Log::error('Erro ao atualizar agendamento personalizado: ' . $e->getMessage());
  188. throw $e;
  189. }
  190. }
  191. public function delete($id)
  192. {
  193. DB::beginTransaction();
  194. try {
  195. $customSchedule = CustomSchedule::findOrFail($id);
  196. $schedule = $customSchedule->schedule;
  197. CustomScheduleSpeciality::where('custom_schedule_id', $customSchedule->id)->delete();
  198. $customSchedule->delete();
  199. $schedule->delete();
  200. DB::commit();
  201. return $customSchedule;
  202. } catch (\Exception $e) {
  203. DB::rollBack();
  204. Log::error('Erro ao excluir agendamento personalizado: ' . $e->getMessage());
  205. throw $e;
  206. }
  207. }
  208. //
  209. public function getAvailableOpportunities($providerId)
  210. {
  211. $provider = Provider::find($providerId);
  212. if (! $provider || ! self::providerHasActivePrimaryBankAccount($providerId)) {
  213. return collect();
  214. }
  215. $providerAddress = Address::where('source', 'provider')
  216. ->where('source_id', $providerId)
  217. ->orderBy('is_primary', 'desc')
  218. ->first();
  219. $providerCityId = $providerAddress?->city_id;
  220. $providerLat = $providerAddress?->latitude !== null ? (float) $providerAddress->latitude : null;
  221. $providerLng = $providerAddress?->longitude !== null ? (float) $providerAddress->longitude : null;
  222. $opportunities = Schedule::with([
  223. 'client.user',
  224. 'client.profileMedia',
  225. 'address:id,district,zip_code,latitude,longitude',
  226. 'customSchedule.serviceType',
  227. 'customSchedule.specialities',
  228. ])
  229. ->leftJoin('schedule_refuses', function ($join) use ($providerId) {
  230. $join->on('schedules.id', '=', 'schedule_refuses.schedule_id')
  231. ->where('schedule_refuses.provider_id', $providerId);
  232. })
  233. ->leftJoin('addresses as opportunity_address', 'opportunity_address.id', '=', 'schedules.address_id')
  234. ->whereNull('schedule_refuses.id')
  235. ->where('schedules.schedule_type', 'custom')
  236. ->where('schedules.status', 'pending')
  237. ->whereNull('schedules.provider_id')
  238. ->whereDate('schedules.date', '>=', now()->toDateString())
  239. ->where(function ($query) use ($providerCityId, $providerLat, $providerLng) {
  240. if ($providerCityId !== null) {
  241. $query->where('opportunity_address.city_id', $providerCityId);
  242. }
  243. if ($providerLat !== null && $providerLng !== null) {
  244. $method = $providerCityId !== null ? 'orWhereRaw' : 'whereRaw';
  245. $query->{$method}(
  246. DistanceService::withinRadiusSqlCondition(
  247. $providerLat,
  248. $providerLng,
  249. self::NEARBY_RADIUS_KM,
  250. 'opportunity_address.latitude',
  251. 'opportunity_address.longitude',
  252. )
  253. );
  254. return;
  255. }
  256. if ($providerCityId === null) {
  257. $query->whereRaw('1 = 0');
  258. }
  259. })
  260. ->select(
  261. 'schedules.id',
  262. 'schedules.client_id',
  263. 'schedules.address_id',
  264. 'schedules.date',
  265. 'schedules.period_type',
  266. 'schedules.start_time',
  267. 'schedules.end_time',
  268. 'schedules.total_amount',
  269. DB::raw("
  270. CASE
  271. WHEN schedules.period_type = '2' THEN {$provider->daily_price_2h}
  272. WHEN schedules.period_type = '4' THEN {$provider->daily_price_4h}
  273. WHEN schedules.period_type = '6' THEN {$provider->daily_price_6h}
  274. WHEN schedules.period_type = '8' THEN {$provider->daily_price_8h}
  275. ELSE 0
  276. END AS total_amount
  277. "),
  278. )
  279. ->get();
  280. $proposedScheduleIds = ScheduleProposal::where('provider_id', $providerId)
  281. ->pluck('schedule_id')
  282. ->flip();
  283. $availableOpportunities = $opportunities->filter(function ($opportunity) use ($providerId, $proposedScheduleIds) {
  284. if ($proposedScheduleIds->has($opportunity->id)) {
  285. return true;
  286. }
  287. try {
  288. return $this->checkProviderAvailability($providerId, $opportunity);
  289. } catch (\Exception $e) {
  290. return false;
  291. }
  292. });
  293. $availableOpportunities->each(function ($opportunity) use ($providerAddress, $proposedScheduleIds) {
  294. $opportunity->distance_km = $this->zipCodeCoordinatesService->calculateDistance(
  295. $providerAddress?->latitude !== null ? (float) $providerAddress->latitude : null,
  296. $providerAddress?->longitude !== null ? (float) $providerAddress->longitude : null,
  297. $providerAddress?->zip_code,
  298. $opportunity->address?->latitude !== null ? (float) $opportunity->address->latitude : null,
  299. $opportunity->address?->longitude !== null ? (float) $opportunity->address->longitude : null,
  300. $opportunity->address?->zip_code,
  301. );
  302. $photoPath = $opportunity->client->profileMedia?->path;
  303. $opportunity->customer_photo = $photoPath
  304. ? Storage::temporaryUrl($photoPath, now()->addMinutes(60))
  305. : null;
  306. $opportunity->proposal_sent = $proposedScheduleIds->has($opportunity->id);
  307. });
  308. return $availableOpportunities->values();
  309. }
  310. /**
  311. * @return Collection<int, int>
  312. */
  313. public function getAvailableProvidersForOpportunity(Schedule $schedule): Collection
  314. {
  315. $schedule->loadMissing(['customSchedule', 'address']);
  316. $candidateIds = $this->getCandidateProviderIdsForOpportunity($schedule);
  317. if ($candidateIds->isEmpty()) {
  318. return $candidateIds;
  319. }
  320. return $candidateIds->filter(function ($providerId) use ($schedule) {
  321. try {
  322. return $this->checkProviderAvailability($providerId, $schedule);
  323. } catch (\Exception $e) {
  324. return false;
  325. }
  326. })->values();
  327. }
  328. public function getOpportunityProposals($scheduleId)
  329. {
  330. return ScheduleProposal::with(['provider.user'])
  331. ->where('schedule_id', $scheduleId)
  332. ->orderBy('created_at', 'desc')
  333. ->get();
  334. }
  335. public function getProvidersProposalsAndOpportunities($providerId)
  336. {
  337. $proposals = $this->getProviderProposals($providerId);
  338. $opportunities = $this->formatCustomSchedules($this->getAvailableOpportunities($providerId));
  339. return [
  340. 'proposals' => $proposals,
  341. 'opportunities' => $opportunities,
  342. ];
  343. }
  344. public function getProviderProposals($providerId)
  345. {
  346. return ScheduleProposal::with([
  347. 'schedule.client.user',
  348. 'schedule.address',
  349. 'schedule.address.city',
  350. 'schedule.address.state',
  351. 'schedule.customSchedule.serviceType',
  352. 'schedule.customSchedule.specialities',
  353. 'schedule.provider.user',
  354. ])
  355. ->where('provider_id', $providerId)
  356. ->orderBy('created_at', 'desc')
  357. ->get();
  358. }
  359. public function getSchedulesCustomGroupedByClient()
  360. {
  361. $schedules = Schedule::with(['client.user', 'provider.user', 'address', 'customSchedule.serviceType', 'customSchedule.specialities', 'reviews.reviewsImprovements.improvementType'])
  362. ->orderBy('id', 'desc')
  363. ->where('schedule_type', 'custom')
  364. ->get();
  365. $grouped = $this->formatCustomSchedules($schedules);
  366. return $grouped;
  367. }
  368. //
  369. public function proposeOpportunity($scheduleId, $providerId)
  370. {
  371. $schedule = Schedule::findOrFail($scheduleId);
  372. if (! self::providerHasActivePrimaryBankAccount($providerId)) {
  373. throw new \Exception(__('messages.provider_missing_bank_account'));
  374. }
  375. if ($schedule->provider_id) {
  376. throw new \Exception(__('validation.custom.opportunity.already_assigned'));
  377. }
  378. $existingProposal = ScheduleProposal::where('schedule_id', $scheduleId)
  379. ->where('provider_id', $providerId)
  380. ->first();
  381. if ($existingProposal) {
  382. throw new \Exception(__('validation.custom.opportunity.proposal_already_sent'));
  383. }
  384. $wasRefused = ScheduleRefuse::where('schedule_id', $scheduleId)
  385. ->where('provider_id', $providerId)
  386. ->exists();
  387. if ($wasRefused) {
  388. throw new \Exception(__('validation.custom.opportunity.provider_refused'));
  389. }
  390. $this->checkProviderAvailability($providerId, $schedule);
  391. $provider = Provider::with([
  392. 'user'
  393. ])->findOrFail($providerId);
  394. $schedule->load([
  395. 'client.user'
  396. ]);
  397. $notificationService = app(NotificationService::class);
  398. $notificationService->create([
  399. 'title' => __('notifications.new_proposal_title'),
  400. 'description' => __('notifications.new_proposal_description', ['provider' => $provider->user->name]),
  401. 'origin' => 'schedule',
  402. 'origin_id' => $schedule->id,
  403. 'type' => NotificationTypeEnum::SCHEDULE_PROVIDER_CLIENT_NEW_SOLICITATION->value,
  404. 'user_id' => $schedule->client->user_id,
  405. ]);
  406. $this->sendProposalReceivedPush($schedule, $provider->user->name);
  407. $proposal = ScheduleProposal::create([
  408. 'schedule_id' => $scheduleId,
  409. 'provider_id' => $providerId,
  410. ]);
  411. $this->realtime->emit(
  412. RealtimeEvent::PROPOSAL_CREATED,
  413. $this->proposalRooms($schedule, $provider),
  414. [
  415. 'entity' => 'schedule_proposal',
  416. 'id' => $proposal->id,
  417. 'schedule_id' => $schedule->id,
  418. ],
  419. );
  420. return $proposal;
  421. }
  422. private function sendProposalReceivedPush(Schedule $schedule, string $providerName): void
  423. {
  424. $user = $schedule->client->user;
  425. if (! $user) {
  426. Log::warning('Push de proposta ignorada: cliente sem usuário', [
  427. 'schedule_id' => $schedule->id,
  428. ]);
  429. return;
  430. }
  431. try {
  432. app(PushNotificationService::class)->sendToUser(
  433. $user,
  434. new PrestadorAceitouPush($providerName, isProposal: true)
  435. );
  436. } catch (\Throwable $exception) {
  437. Log::error('Falha ao enviar push de nova proposta sob medida', [
  438. 'schedule_id' => $schedule->id,
  439. 'user_id' => $user->id,
  440. 'error' => $exception->getMessage(),
  441. ]);
  442. }
  443. }
  444. public function refuseOpportunity($scheduleId, $providerId)
  445. {
  446. $schedule = Schedule::with(['client.user'])->findOrFail($scheduleId);
  447. $provider = Provider::with(['user'])->findOrFail($providerId);
  448. $schedule_refuse = ScheduleRefuse::create([
  449. 'schedule_id' => $scheduleId,
  450. 'provider_id' => $providerId,
  451. ]);
  452. $notificationService = app(NotificationService::class);
  453. $notificationService->create([
  454. 'title' => __('notifications.opportunity_refused_title'),
  455. 'description' => __('notifications.opportunity_refused_description', [
  456. 'provider' => $provider->user->name,
  457. ]),
  458. 'origin' => 'schedule',
  459. 'origin_id' => $scheduleId,
  460. 'type' => NotificationTypeEnum::SCHEDULE_CLIENT_PROVIDER_REFUSED->value,
  461. 'user_id' => $schedule->client->user_id,
  462. ]);
  463. // Push notification
  464. if ($schedule->client?->user) {
  465. try {
  466. app(PushNotificationService::class)->sendToUser(
  467. $schedule->client->user,
  468. new PrestadorRecusouPush(
  469. $provider->user->name
  470. )
  471. );
  472. } catch (\Throwable $exception) {
  473. Log::error('Falha ao enviar push de recusa da oportunidade', [
  474. 'schedule_id' => $schedule->id,
  475. 'provider_id' => $providerId,
  476. 'user_id' => $schedule->client->user->id,
  477. 'error' => $exception->getMessage(),
  478. ]);
  479. }
  480. }
  481. $this->realtime->emit(
  482. RealtimeEvent::PROPOSAL_REFUSED,
  483. $this->proposalRooms($schedule, $provider),
  484. [
  485. 'entity' => 'schedule_refuse',
  486. 'id' => $schedule_refuse->id,
  487. 'schedule_id' => $schedule->id,
  488. 'actor' => 'provider',
  489. ],
  490. );
  491. return $schedule_refuse;
  492. }
  493. /**
  494. * Salas dos dois lados de uma proposta, mais quem estiver com o
  495. * agendamento aberto.
  496. *
  497. * @return RealtimeRoom[]
  498. */
  499. private function proposalRooms(Schedule $schedule, Provider $provider): array
  500. {
  501. $rooms = [
  502. RealtimeRoom::schedule($schedule->id),
  503. ];
  504. if ($schedule->client?->user_id) {
  505. $rooms[] = RealtimeRoom::user($schedule->client->user_id);
  506. }
  507. if ($provider->user_id) {
  508. $rooms[] = RealtimeRoom::user($provider->user_id);
  509. }
  510. return $rooms;
  511. }
  512. //
  513. public function acceptProposal($proposalId)
  514. {
  515. return DB::transaction(function () use ($proposalId) {
  516. $proposal = ScheduleProposal::findOrFail($proposalId);
  517. $schedule = $proposal->schedule;
  518. if ($schedule->provider_id) {
  519. throw new \Exception(__('validation.custom.opportunity.already_assigned'));
  520. }
  521. $provider = Provider::find($proposal->provider_id);
  522. $schedule->total_amount = $this->resolveProposalAmount($schedule, $provider);
  523. $schedule->save();
  524. $schedule->update([
  525. 'provider_id' => $proposal->provider_id,
  526. ]);
  527. $schedule->refresh();
  528. $schedule->load(['provider.user', 'client.user']);
  529. $notificationService = app(NotificationService::class);
  530. $notificationService->create([
  531. 'title' => __('notifications.proposal_accepted_title'),
  532. 'description' => __('notifications.proposal_accepted_description'),
  533. 'origin' => 'schedule',
  534. 'origin_id' => $schedule->id,
  535. 'type' => NotificationTypeEnum::SCHEDULE_PROVIDER_CLIENT_PROPOSAL_ACCEPTED->value,
  536. 'user_id' => $provider->user_id,
  537. ]);
  538. app(ScheduleService::class)->updateStatus($schedule->id, 'paid');
  539. ScheduleProposal::where('schedule_id', $schedule->id)
  540. ->where('id', '!=', $proposalId)
  541. ->delete();
  542. $servicePackage = ServicePackage::create([
  543. 'client_id' => $schedule->client_id,
  544. 'provider_id' => $schedule->provider_id,
  545. ]);
  546. $servicePackage->items()->create([
  547. 'schedule_id' => $schedule->id,
  548. ]);
  549. $this->realtime->emit(
  550. RealtimeEvent::PROPOSAL_ACCEPTED,
  551. $this->proposalRooms($schedule, $provider),
  552. [
  553. 'entity' => 'schedule_proposal',
  554. 'id' => $proposalId,
  555. 'schedule_id' => $schedule->id,
  556. 'service_package_id' => $servicePackage->id,
  557. ],
  558. );
  559. return $servicePackage->fresh([
  560. 'items.schedule.client.user',
  561. 'items.schedule.provider.user',
  562. 'items.schedule.address',
  563. 'provider.user',
  564. ]);
  565. });
  566. }
  567. public function resolveProposalAmount(Schedule $schedule, Provider $provider): float
  568. {
  569. switch ($schedule->period_type) {
  570. case '8':
  571. return (float) $provider->daily_price_8h;
  572. case '6':
  573. return (float) $provider->daily_price_6h;
  574. case '4':
  575. return (float) $provider->daily_price_4h;
  576. case '2':
  577. return (float) $provider->daily_price_2h;
  578. default:
  579. throw new \Exception(__('messages.invalid_schedule_period'));
  580. }
  581. }
  582. public function refuseProposal($proposalId)
  583. {
  584. return DB::transaction(function () use ($proposalId) {
  585. $proposal = ScheduleProposal::findOrFail($proposalId);
  586. ScheduleRefuse::create([
  587. 'schedule_id' => $proposal->schedule_id,
  588. 'provider_id' => $proposal->provider_id,
  589. ]);
  590. $notificationService = app(NotificationService::class);
  591. $notificationService->create([
  592. 'title' => __('notifications.proposal_refused_title'),
  593. 'description' => __('notifications.proposal_refused_description'),
  594. 'origin' => 'schedule',
  595. 'origin_id' => $proposal->schedule_id,
  596. 'type' => NotificationTypeEnum::SCHEDULE_CLIENT_PROVIDER_REFUSED->value,
  597. 'user_id' => $proposal->provider->user_id,
  598. ]);
  599. // Push notification
  600. if ($proposal->provider?->user) {
  601. try {
  602. app(PushNotificationService::class)->sendToUser(
  603. $proposal->provider->user,
  604. new ClienteRecusouPush(
  605. $proposal->schedule?->client?->user?->name
  606. )
  607. );
  608. } catch (\Throwable $exception) {
  609. Log::error('Falha ao enviar push de recusa da proposta', [
  610. 'proposal_id' => $proposal->id,
  611. 'schedule_id' => $proposal->schedule_id,
  612. 'provider_id' => $proposal->provider_id,
  613. 'user_id' => $proposal->provider->user->id,
  614. 'error' => $exception->getMessage(),
  615. ]);
  616. }
  617. }
  618. $scheduleId = $proposal->schedule_id;
  619. $rooms = [
  620. RealtimeRoom::schedule($scheduleId),
  621. ];
  622. if ($proposal->schedule?->client?->user_id) {
  623. $rooms[] = RealtimeRoom::user($proposal->schedule->client->user_id);
  624. }
  625. if ($proposal->provider?->user_id) {
  626. $rooms[] = RealtimeRoom::user($proposal->provider->user_id);
  627. }
  628. $proposal->delete();
  629. $this->realtime->emit(
  630. RealtimeEvent::PROPOSAL_REFUSED,
  631. $rooms,
  632. [
  633. 'entity' => 'schedule_proposal',
  634. 'id' => $proposalId,
  635. 'schedule_id' => $scheduleId,
  636. 'actor' => 'client',
  637. ],
  638. );
  639. return true;
  640. });
  641. }
  642. //
  643. public function formatCustomSchedules($schedules)
  644. {
  645. $grouped = $schedules->groupBy('client_id')->map(function ($clientSchedules) {
  646. $firstSchedule = $clientSchedules->first();
  647. $clientPhotoPath = $firstSchedule->client->profileMedia?->path;
  648. return [
  649. 'client_id' => $firstSchedule->client_id,
  650. 'client_name' => $firstSchedule->client->user->name ?? 'N/A',
  651. 'customer_photo' => $clientPhotoPath
  652. ? Storage::temporaryUrl($clientPhotoPath, now()->addMinutes(60))
  653. : null,
  654. 'schedules' => $clientSchedules->map(function ($schedule) {
  655. $customSchedule = $schedule->customSchedule;
  656. return [
  657. 'id' => $schedule->id,
  658. 'date' => $schedule->date ? Carbon::parse($schedule->date)->format('d/m/Y') : null,
  659. 'start_time' => $schedule->start_time,
  660. 'end_time' => $schedule->end_time,
  661. 'period_type' => $schedule->period_type,
  662. 'status' => $schedule->status,
  663. 'total_amount' => $schedule->total_amount,
  664. 'code' => $schedule->code,
  665. 'code_verified' => $schedule->code_verified,
  666. 'provider_id' => $schedule->provider_id,
  667. 'client_id' => $schedule->client_id,
  668. 'provider_name' => $schedule->provider?->user->name ?? 'N/A',
  669. 'address' => $schedule->address ? [
  670. 'id' => $schedule->address->id,
  671. 'address' => $schedule->address->address,
  672. 'complement' => $schedule->address->complement,
  673. 'zip_code' => $schedule->address->zip_code,
  674. 'city' => $schedule->address->city->name ?? '',
  675. 'state' => $schedule->address->city->state->name ?? '',
  676. ] : null,
  677. 'client_name' => $schedule->client->user->name ?? 'N/A',
  678. 'custom_schedule' => $customSchedule ? [
  679. 'id' => $customSchedule->id,
  680. 'address_type' => $customSchedule->address_type,
  681. 'service_type_id' => $customSchedule->service_type_id,
  682. 'service_type_name' => $customSchedule->serviceType?->description ?? 'N/A',
  683. 'description' => $customSchedule->description,
  684. 'min_price' => $customSchedule->min_price,
  685. 'max_price' => $customSchedule->max_price,
  686. 'offers_meal' => $customSchedule->offers_meal,
  687. 'specialities' => $customSchedule->specialities->map(function ($speciality) {
  688. return [
  689. 'id' => $speciality->id,
  690. 'description' => $speciality->description,
  691. ];
  692. })->values(),
  693. ] : null,
  694. 'reviews' => $schedule->reviews->map(function ($review) {
  695. return [
  696. 'id' => $review->id,
  697. 'stars' => $review->stars,
  698. 'comment' => $review->comment,
  699. 'origin' => $review->origin,
  700. 'origin_id' => $review->origin_id,
  701. 'created_at' => Carbon::parse($review->created_at)->format('Y-m-d H:i'),
  702. 'updated_at' => Carbon::parse($review->updated_at)->format('Y-m-d H:i'),
  703. 'improvements' => $review->reviewsImprovements->map(function ($ri) {
  704. return [
  705. 'id' => $ri->id,
  706. 'improvement_type_id' => $ri->improvement_type_id,
  707. 'improvement_type_name' => $ri->improvementType ? $ri->improvementType->description : null,
  708. ];
  709. })->values(),
  710. ];
  711. }),
  712. ];
  713. })->values(),
  714. ];
  715. })->sortBy('id')->values();
  716. return $grouped;
  717. }
  718. public function verifyScheduleCode($scheduleId, $code)
  719. {
  720. $schedule = Schedule::findOrFail($scheduleId);
  721. if ($schedule->code_verified) {
  722. throw new \Exception(__('validation.custom.opportunity.code_already_verified'));
  723. }
  724. if ($schedule->code !== $code) {
  725. throw new \Exception(__('validation.custom.opportunity.invalid_code'));
  726. }
  727. $schedule->update([
  728. 'code_verified' => true,
  729. ]);
  730. return $schedule;
  731. }
  732. //
  733. /**
  734. * @return Collection<int, int>
  735. */
  736. private static function providerHasActivePrimaryBankAccount($providerId): bool
  737. {
  738. return Provider::query()
  739. ->where('providers.id', $providerId)
  740. ->whereExists(Provider::hasActivePrimaryBankAccount())
  741. ->exists();
  742. }
  743. private function getCandidateProviderIdsForOpportunity(Schedule $schedule): Collection
  744. {
  745. $address = Address::find($schedule->address_id);
  746. $cityId = $address?->city_id;
  747. $lat = $address?->latitude !== null ? (float) $address->latitude : null;
  748. $lng = $address?->longitude !== null ? (float) $address->longitude : null;
  749. if ($cityId === null && ($lat === null || $lng === null)) {
  750. Log::warning('Oportunidade sem geolocalizacao; nenhum prestador elegivel', [
  751. 'schedule_id' => $schedule->id,
  752. 'address_id' => $schedule->address_id,
  753. ]);
  754. return collect();
  755. }
  756. $periodType = (string) $schedule->period_type;
  757. $factor = match ($periodType) {
  758. '2' => 0.30,
  759. '4' => 0.55,
  760. '6' => 0.85,
  761. '8' => 1.00,
  762. default => null,
  763. };
  764. if ($factor === null) {
  765. return collect();
  766. }
  767. $priceColumn = "providers.daily_price_{$periodType}h";
  768. $date = Carbon::parse($schedule->date);
  769. $dayOfWeek = $date->dayOfWeek;
  770. $period = $schedule->start_time < '13:00:00' ? 'morning' : 'afternoon';
  771. $minProportional = (float) $schedule->customSchedule->min_price * $factor;
  772. $maxProportional = (float) $schedule->customSchedule->max_price * $factor;
  773. $providerAddressSubquery = DB::raw("
  774. (
  775. SELECT DISTINCT ON (source_id)
  776. *
  777. FROM addresses
  778. WHERE
  779. source = 'provider'
  780. AND deleted_at IS NULL
  781. ORDER BY
  782. source_id,
  783. (latitude IS NOT NULL AND longitude IS NOT NULL) DESC,
  784. is_primary DESC,
  785. id DESC
  786. ) AS provider_address
  787. ");
  788. return Provider::query()
  789. ->join($providerAddressSubquery, 'provider_address.source_id', '=', 'providers.id')
  790. ->where('providers.approval_status', ApprovalStatusEnum::ACCEPTED->value)
  791. ->whereExists(Provider::hasActivePrimaryBankAccount())
  792. ->where(function ($query) use ($cityId, $lat, $lng) {
  793. if ($cityId !== null) {
  794. $query->orWhere('provider_address.city_id', $cityId);
  795. }
  796. if ($lat !== null && $lng !== null) {
  797. $query->orWhereRaw(
  798. DistanceService::withinRadiusSqlCondition(
  799. $lat,
  800. $lng,
  801. self::NEARBY_RADIUS_KM,
  802. 'provider_address.latitude',
  803. 'provider_address.longitude',
  804. )
  805. );
  806. }
  807. })
  808. ->whereExists(function ($query) use ($dayOfWeek, $period) {
  809. $query->select(DB::raw(1))
  810. ->from('provider_working_days')
  811. ->whereColumn('provider_working_days.provider_id', 'providers.id')
  812. ->where('provider_working_days.day', $dayOfWeek)
  813. ->where('provider_working_days.period', $period)
  814. ->whereNull('provider_working_days.deleted_at');
  815. })
  816. ->whereNotNull($priceColumn)
  817. ->whereBetween($priceColumn, [$minProportional, $maxProportional])
  818. ->whereNotIn(
  819. 'providers.id',
  820. ScheduleBusinessRules::getBlockedProviderIdsForClient($schedule->client_id)
  821. )
  822. ->pluck('providers.id');
  823. }
  824. private function dispatchOpportunityNotification(array $customSchedules): void
  825. {
  826. $scheduleIds = [];
  827. try {
  828. $scheduleIds = collect($customSchedules)
  829. ->pluck('schedule_id')
  830. ->filter()
  831. ->values()
  832. ->all();
  833. if (empty($scheduleIds)) {
  834. return;
  835. }
  836. NotifyProvidersOfNewOpportunityJob::dispatch($scheduleIds);
  837. } catch (\Throwable $exception) {
  838. Log::error('Falha ao enfileirar notificacao de nova oportunidade', [
  839. 'schedule_ids' => $scheduleIds,
  840. 'error' => $exception->getMessage(),
  841. ]);
  842. }
  843. }
  844. private function checkProviderAvailability($providerId, $schedule)
  845. {
  846. $client_id = $schedule->client_id;
  847. $provider_id = $providerId;
  848. $date = Carbon::parse($schedule->date);
  849. $dayOfWeek = $date->dayOfWeek; // 0-6
  850. $startTime = $schedule->start_time;
  851. $endTime = $schedule->end_time;
  852. $date_ymd = $date->format('Y-m-d');
  853. $period = $startTime < '13:00:00' ? 'morning' : 'afternoon';
  854. $period_type = $schedule->period_type; // 2,4,6,8
  855. // bloqueio 2 schedules por semana para o mesmo client e provider
  856. ScheduleBusinessRules::validateWeeklyScheduleLimit(
  857. $client_id,
  858. $provider_id,
  859. $date_ymd
  860. );
  861. // bloqueio provider trabalha no dia/periodo
  862. ScheduleBusinessRules::validateWorkingDay(
  863. $provider_id,
  864. $dayOfWeek,
  865. $period
  866. );
  867. // bloqueio provider tem blockedday para dia/hora
  868. ScheduleBusinessRules::validateBlockedDay(
  869. $provider_id,
  870. $date_ymd,
  871. $startTime,
  872. $endTime
  873. );
  874. // bloqueio daily_price do provider esta fora do range min_price e max_price
  875. ScheduleBusinessRules::validatePricePeriod(
  876. $provider_id,
  877. $schedule->customSchedule->min_price,
  878. $schedule->customSchedule->max_price,
  879. $period_type
  880. );
  881. // bloqueio provider tem outro agendamento para dia/hora
  882. ScheduleBusinessRules::validateConflictingSchedule(
  883. $provider_id,
  884. $date_ymd,
  885. $startTime,
  886. $endTime
  887. );
  888. // bloqueio provider tem outra proposta para o mesmo agendamento
  889. ScheduleBusinessRules::validateConflictingSameProposal(
  890. $provider_id,
  891. $schedule->id
  892. );
  893. // bloqueio provider tem outra proposta na mesma data
  894. ScheduleBusinessRules::validateConflictingProposalSameDate(
  895. $provider_id,
  896. $date_ymd,
  897. $startTime,
  898. $endTime,
  899. $schedule->id
  900. );
  901. // bloqueio caso o client tenha bloqueado o provider
  902. ScheduleBusinessRules::validateClientNotBlockedByProvider(
  903. $client_id,
  904. $provider_id
  905. );
  906. // bloqueio caso o provider tenha bloqueado o client
  907. ScheduleBusinessRules::validateProviderNotBlockedByClient(
  908. $client_id,
  909. $provider_id
  910. );
  911. return true;
  912. }
  913. }