ScheduleBusinessRules.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. <?php
  2. namespace App\Rules;
  3. use App\Enums\BlockedPeriodEnum;
  4. use App\Models\ClientProviderBlock;
  5. use App\Models\Provider;
  6. use App\Models\ProviderBlockedDay;
  7. use App\Models\ProviderClientBlock;
  8. use App\Models\ProviderWorkingDay;
  9. use App\Models\Schedule;
  10. use App\Models\ScheduleProposal;
  11. use Carbon\Carbon;
  12. use Illuminate\Support\Collection;
  13. class ScheduleBusinessRules
  14. {
  15. // Status que devem ser ignorados na validação de limite por semana
  16. private const EXCLUDED_STATUSES = ['cancelled', 'rejected'];
  17. public static function validateProviderVisibleToCustomers($providerId): bool
  18. {
  19. $provider = Provider::query()
  20. ->visibleToCustomers()
  21. ->where('providers.id', $providerId)
  22. ->exists();
  23. if (! $provider) {
  24. throw new \Exception(__('messages.provider_unavailable_for_schedule'));
  25. }
  26. return true;
  27. }
  28. /**
  29. * Valida se o prestador pode ter mais um agendamento com o cliente na semana
  30. * Limite: 2 agendamentos por semana (domingo a sábado)
  31. *
  32. * @param int $clientId
  33. * @param int $providerId
  34. * @param string $date (Y-m-d)
  35. * @param int|null $excludeScheduleId
  36. * @return bool
  37. *
  38. * @throws \Exception
  39. */
  40. public static function validateWeeklyScheduleLimit($clientId, $providerId, $date, $excludeScheduleId = null)
  41. {
  42. $date = Carbon::parse($date);
  43. $weekStart = $date->copy()->startOfWeek(Carbon::SUNDAY);
  44. $weekEnd = $date->copy()->endOfWeek(Carbon::SATURDAY);
  45. $weeklySchedulesCount = Schedule::where('client_id', $clientId)
  46. ->where('provider_id', $providerId)
  47. ->whereNotIn('status', self::EXCLUDED_STATUSES)
  48. ->whereBetween('date', [$weekStart->format('Y-m-d'), $weekEnd->format('Y-m-d')])
  49. ->when($excludeScheduleId, function ($query) use ($excludeScheduleId) {
  50. $query->where('id', '!=', $excludeScheduleId);
  51. })
  52. ->count();
  53. if ($weeklySchedulesCount >= 2) {
  54. throw new \Exception(__('validation.custom.schedule.weekly_limit_exceeded'));
  55. }
  56. return true;
  57. }
  58. /**
  59. * Valida se o prestador tem horário de trabalho cadastrado para o dia da semana e período
  60. *
  61. * @param int $provider_id
  62. * @param int $day_of_week (0 - domingo, 6 - sábado)
  63. * @param string $period ('morning' ou 'afternoon')
  64. * @return bool
  65. *
  66. * @throws \Exception
  67. */
  68. public static function validateWorkingDay($provider_id, $day_of_week, $period)
  69. {
  70. $workingDay = ProviderWorkingDay::where('provider_id', $provider_id)
  71. ->where('day', $day_of_week)
  72. ->where('period', $period)
  73. ->first();
  74. if (! $workingDay) {
  75. throw new \Exception(__('validation.custom.schedule.provider_not_working'));
  76. }
  77. return true;
  78. }
  79. /**
  80. * Valida se o prestador tem bloqueio cadastrado para o dia e horário
  81. *
  82. * @param int $provider_id
  83. * @param string $date_ymd (Y-m-d)
  84. * @param string $start_time (H:i:s)
  85. * @param string $end_time (H:i:s)
  86. * @return bool
  87. *
  88. * @throws \Exception
  89. */
  90. public static function validateBlockedDay($provider_id, $date_ymd, $start_time, $end_time)
  91. {
  92. $blockedDay = ProviderBlockedDay::where('provider_id', $provider_id)
  93. ->whereDate('date', $date_ymd)
  94. ->where(function ($query) use ($start_time, $end_time) {
  95. $query->where('period', BlockedPeriodEnum::ALL->value)
  96. ->orWhere(function ($q) use ($start_time, $end_time) {
  97. $q->whereIn('period', [
  98. BlockedPeriodEnum::MORNING->value,
  99. BlockedPeriodEnum::AFTERNOON->value,
  100. ])
  101. ->where('init_hour', '<', $end_time)
  102. ->where('end_hour', '>', $start_time);
  103. });
  104. })
  105. ->first();
  106. if ($blockedDay) {
  107. throw new \Exception(__('validation.custom.schedule.provider_blocked'));
  108. }
  109. return true;
  110. }
  111. // apenas para custom_schedules
  112. public static function validatePricePeriod($provider_id, $min_price, $max_price, $period_type)
  113. {
  114. if ($min_price < 0 || $max_price < 0) {
  115. throw new \Exception(__('validation.custom.schedule.invalid_price'));
  116. }
  117. if ($min_price > $max_price) {
  118. throw new \Exception(__('validation.custom.schedule.invalid_price_range'));
  119. }
  120. $provider = Provider::find($provider_id);
  121. $min_price_proportional = 0;
  122. $max_price_proportional = 0;
  123. $provider_price_period = 0;
  124. switch ($period_type) {
  125. case '2': // 2 horas
  126. $provider_price_period = $provider->daily_price_2h;
  127. $min_price_proportional = $min_price * 0.30;
  128. $max_price_proportional = $max_price * 0.30;
  129. break;
  130. case '4': // 4 horas
  131. $provider_price_period = $provider->daily_price_4h;
  132. $min_price_proportional = $min_price * 0.55;
  133. $max_price_proportional = $max_price * 0.55;
  134. break;
  135. case '6': // 6 horas
  136. $provider_price_period = $provider->daily_price_6h;
  137. $min_price_proportional = $min_price * 0.85;
  138. $max_price_proportional = $max_price * 0.85;
  139. break;
  140. case '8': // 8 horas
  141. $provider_price_period = $provider->daily_price_8h;
  142. $min_price_proportional = $min_price;
  143. $max_price_proportional = $max_price;
  144. break;
  145. default:
  146. throw new \Exception(__('validation.custom.schedule.invalid_period_type'));
  147. }
  148. if ($provider_price_period < $min_price_proportional || $provider_price_period > $max_price_proportional) {
  149. throw new \Exception(__('validation.custom.schedule.price_not_in_range'));
  150. }
  151. return true;
  152. }
  153. /**
  154. * Valida se o prestador tem outro agendamento no mesmo dia e horário
  155. *
  156. * @param int $provider_id
  157. * @param string $date_ymd (Y-m-d)
  158. * @param string $start_time (H:i:s)
  159. * @param string $end_time (H:i:s)
  160. * @param int|null $exclude_schedule_id (id do agendamento a ser excluído da validação, usado para edição de agendamento)
  161. * @return bool
  162. *
  163. * @throws \Exception
  164. */
  165. public static function validateConflictingSchedule($provider_id, $date_ymd, $start_time, $end_time, $exclude_schedule_id = null)
  166. {
  167. $conflictingSchedule = Schedule::where('provider_id', $provider_id)
  168. ->where('date', $date_ymd)
  169. ->whereIn('status', ['pending', 'accepted', 'paid', 'started'])
  170. ->where(function ($query) use ($start_time, $end_time) {
  171. $query->whereBetween('start_time', [$start_time, $end_time])
  172. ->orWhereBetween('end_time', [$start_time, $end_time])
  173. ->orWhere(function ($q) use ($start_time, $end_time) {
  174. $q->where('start_time', '<=', $start_time)
  175. ->where('end_time', '>=', $end_time);
  176. });
  177. })
  178. ->when($exclude_schedule_id, function ($query) use ($exclude_schedule_id) {
  179. $query->where('id', '!=', $exclude_schedule_id);
  180. })
  181. ->first();
  182. if ($conflictingSchedule) {
  183. throw new \Exception(__('validation.custom.schedule.provider_conflicting_schedule'));
  184. }
  185. return true;
  186. }
  187. /**
  188. * Valida se o prestador tem outro agendamento com o mesmo cliente no mesmo dia e horário
  189. *
  190. * @param int $provider_id
  191. * @param string $date_ymd (Y-m-d)
  192. * @param string $start_time (H:i:s)
  193. * @param string $end_time (H:i:s)
  194. * @param int|null $exclude_schedule_id (id do agendamento a ser excluído da validação, usado para edição de agendamento)
  195. * @return bool
  196. *
  197. * @throws \Exception
  198. */
  199. public static function validateConflictingSameProposal($provider_id, $schedule_id)
  200. {
  201. $conflictingSameProposal = ScheduleProposal::where('schedule_proposals.provider_id', $provider_id)
  202. ->where('schedule_proposals.schedule_id', $schedule_id)
  203. ->leftJoin('schedules', 'schedule_proposals.schedule_id', '=', 'schedules.id')
  204. ->whereNotIn('schedules.status', self::EXCLUDED_STATUSES)
  205. ->first();
  206. if ($conflictingSameProposal) {
  207. throw new \Exception(__('validation.custom.schedule.provider_conflicting_same_proposal'));
  208. }
  209. return true;
  210. }
  211. /**
  212. * Valida se o prestador tem outro agendamento com o mesmo cliente no mesmo dia e horário, ignorando o horário
  213. *
  214. * @param int $provider_id
  215. * @param string $date_ymd (Y-m-d)
  216. * @param string $start_time (H:i:s)
  217. * @param string $end_time (H:i:s)
  218. * @param int|null $exclude_schedule_id (id do agendamento a ser excluído da validação, usado para edição de agendamento)
  219. * @return bool
  220. *
  221. * @throws \Exception
  222. */
  223. public static function validateConflictingProposalSameDate($provider_id, $date_ymd, $start_time, $end_time, $exclude_schedule_id = null)
  224. {
  225. $conflictingProposalSameDate = ScheduleProposal::where('schedule_proposals.provider_id', $provider_id)
  226. ->leftJoin('schedules', 'schedule_proposals.schedule_id', '=', 'schedules.id')
  227. ->where('schedules.date', $date_ymd)
  228. ->where(function ($query) use ($start_time, $end_time) {
  229. $query->whereBetween('schedules.start_time', [$start_time, $end_time])
  230. ->orWhereBetween('schedules.end_time', [$start_time, $end_time])
  231. ->orWhere(function ($q) use ($start_time, $end_time) {
  232. $q->where('schedules.start_time', '<=', $start_time)
  233. ->where('schedules.end_time', '>=', $end_time);
  234. });
  235. })
  236. ->whereNotIn('status', self::EXCLUDED_STATUSES)
  237. ->when($exclude_schedule_id, function ($query) use ($exclude_schedule_id) {
  238. $query->where('schedules.id', '!=', $exclude_schedule_id);
  239. })
  240. ->first();
  241. if ($conflictingProposalSameDate) {
  242. throw new \Exception(__('validation.custom.schedule.provider_conflicting_proposal_same_date'));
  243. }
  244. return true;
  245. }
  246. /**
  247. * Valida se o cliente tem bloqueio cadastrado para o prestador
  248. *
  249. * @param int $client_id
  250. * @param int $provider_id
  251. * @return bool
  252. *
  253. * @throws \Exception
  254. */
  255. public static function validateClientNotBlockedByProvider($client_id, $provider_id)
  256. {
  257. $provider_client_block = ProviderClientBlock::where('provider_id', $provider_id)
  258. ->where('client_id', $client_id)
  259. ->first();
  260. if ($provider_client_block) {
  261. throw new \Exception(__('validation.custom.schedule.client_blocked_by_provider'));
  262. }
  263. return true;
  264. }
  265. /**
  266. * Valida se o prestador tem bloqueio cadastrado para o cliente
  267. *
  268. * @param int $client_id
  269. * @param int $provider_id
  270. * @return bool
  271. *
  272. * @throws \Exception
  273. */
  274. public static function validateProviderNotBlockedByClient($client_id, $provider_id)
  275. {
  276. $client_provider_block = ClientProviderBlock::where('provider_id', $provider_id)
  277. ->where('client_id', $client_id)
  278. ->first();
  279. if ($client_provider_block) {
  280. throw new \Exception(__('validation.custom.schedule.provider_blocked_by_client'));
  281. }
  282. return true;
  283. }
  284. // -------------------------------------------------------------------------
  285. // Métodos de consulta em batch — usados para filtragem em listagens.
  286. // Não lançam exceção: retornam coleções de IDs para uso em whereIn/whereNotIn.
  287. // -------------------------------------------------------------------------
  288. /**
  289. * Retorna os IDs de prestadores que bloquearam o cliente OU foram bloqueados por ele.
  290. * Centraliza ambas as direções de bloqueio em um único método para uso em listagens.
  291. */
  292. public static function getBlockedProviderIdsForClient(int $client_id): Collection
  293. {
  294. // Prestadores que bloquearam este cliente (ProviderClientBlock)
  295. $blockedByProvider = ProviderClientBlock::where('client_id', $client_id)
  296. ->pluck('provider_id');
  297. // Prestadores que este cliente bloqueou (ClientProviderBlock)
  298. $blockedByClient = ClientProviderBlock::where('client_id', $client_id)
  299. ->pluck('provider_id');
  300. return $blockedByProvider->merge($blockedByClient)->unique()->values();
  301. }
  302. /**
  303. * Retorna os IDs de prestadores que possuem pelo menos um dia de trabalho cadastrado.
  304. * Garante que apenas prestadores ativos na plataforma apareçam em listagens.
  305. */
  306. public static function getProviderIdsWithWorkingDays(): Collection
  307. {
  308. return ProviderWorkingDay::select('provider_id')
  309. ->distinct()
  310. ->pluck('provider_id');
  311. }
  312. /**
  313. * Retorna os IDs de prestadores disponíveis em uma data específica,
  314. * dentro de um conjunto pré-filtrado de IDs.
  315. *
  316. * Regras (em batch, sem iteração PHP):
  317. * 1. Prestador tem pelo menos um ProviderWorkingDay para o day_of_week da data.
  318. * 2. Prestador NÃO tem ProviderBlockedDay com period = 'all' nessa data.
  319. * (period = 'morning' ou 'afternoon' = bloqueio parcial → ainda disponível)
  320. *
  321. * @param string $date_ymd Y-m-d
  322. * @param Collection $providerIds conjunto de IDs a filtrar
  323. */
  324. public static function getAvailableProviderIdsForDate(string $date_ymd, Collection $providerIds): Collection
  325. {
  326. $dayOfWeek = Carbon::parse($date_ymd)->dayOfWeek;
  327. $withWorkingDay = ProviderWorkingDay::whereIn('provider_id', $providerIds)
  328. ->where('day', $dayOfWeek)
  329. ->pluck('provider_id')
  330. ->unique();
  331. $fullyBlockedIds = ProviderBlockedDay::whereIn('provider_id', $withWorkingDay)
  332. ->where('date', $date_ymd)
  333. ->where('period', 'all')
  334. ->pluck('provider_id')
  335. ->unique();
  336. return $withWorkingDay->diff($fullyBlockedIds)->values();
  337. }
  338. public static function validateProviderAbsenceWindow(Schedule $schedule): bool
  339. {
  340. $date = Carbon::parse($schedule->date)->format('Y-m-d');
  341. $scheduleStart = Carbon::parse(
  342. $date . ' ' . $schedule->start_time
  343. );
  344. $scheduleEnd = Carbon::parse(
  345. $date . ' ' . $schedule->end_time
  346. );
  347. $now = Carbon::now();
  348. // O cliente só pode informar a falta após 30 minutos do início.
  349. $absenceAvailableAt = $scheduleStart->copy()->addMinutes(30);
  350. if ($now->lt($absenceAvailableAt)) {
  351. throw new \Exception(
  352. __('messages.provider_absence_not_available_yet')
  353. );
  354. }
  355. // A informação de falta só pode ser feita até o término do serviço.
  356. if ($now->gt($scheduleEnd)) {
  357. throw new \Exception(
  358. __('messages.provider_absence_window_expired')
  359. );
  360. }
  361. return true;
  362. }
  363. }