PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / trunk
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution vtrunk
2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 1.7.2 All 33 releases
fluent-booking / app / Services / TimeSlotService.php

TimeSlotService.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution trunk, at app/Services/TimeSlotService.php

1,114 lines 40.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Services;
4
5 use FluentBooking\App\Models\Booking;
6 use FluentBooking\App\Models\Calendar;
7 use FluentBooking\App\Models\CalendarSlot;
8 use FluentBooking\Framework\Support\Arr;
9 use FluentBooking\Framework\Support\DateTime;
10
11 class TimeSlotService
12 {
13 protected $calendarSlot;
14
15 protected $calendar;
16
17 protected $hostId = null;
18
19 public function __construct(Calendar $calendar, CalendarSlot $calendarSlot)
20 {
21 $this->hostId = null;
22 $this->calendar = $calendar;
23 $this->calendarSlot = $calendarSlot;
24 }
25
26 public function getDates($fromDate = false, $toDate = false, $duration = null, $isDoingBooking = false, $timeZone = 'UTC')
27 {
28 $duration = $this->calendarSlot->getDuration($duration);
29
30 $fromDate = $fromDate ?: gmdate('Y-m-d'); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
31 $toDate = $toDate ?: gmdate('Y-m-t 23:59:59', strtotime($fromDate)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
32
33 $ranges = $this->getCurrentDateRange($fromDate, $toDate);
34
35 $bookedSlots = $this->getBookedSlots([$fromDate, $toDate], 'UTC', $isDoingBooking);
36
37 $ranges = $this->maybeBookingFrequencyLimitRanges($ranges);
38 $ranges = $this->maybeBookingDurationLimitRanges($ranges, $duration);
39
40 $cutOutTime = DateTimeHelper::getTimestamp() + $this->calendarSlot->getCutoutSeconds();
41
42 $maxBookingTime = $this->getMaxBookingTimestamp($fromDate, $toDate, $timeZone);
43
44 $timezoneInfo = $this->getTimezoneInfo();
45
46 $rangedSlots = $this->getRangedValidSlots($ranges, $duration, $bookedSlots, $cutOutTime, $maxBookingTime, $timezoneInfo);
47
48 $rangedSlots = $this->maybeBookingPerDayLimitSlots($rangedSlots, $bookedSlots, $duration);
49
50 return $rangedSlots;
51 }
52
53 protected function getRangedValidSlots($ranges, $duration, $bookedSlots, $cutOutTime, $maxBookingTime, $timezoneInfo, $rangedSlots = [], $hostId = null)
54 {
55 $period = $duration * 60;
56
57 $hostId = $hostId ?: $this->hostId;
58
59 $bufferTime = $this->calendarSlot->getTotalBufferTime() * 60;
60
61 $daySlots = $this->getWeekDaySlots($duration, $hostId);
62
63 $dateOverrides = $this->calendarSlot->getDateOverrides($hostId);
64
65 list($scheduleTimezone, $dstTime) = $timezoneInfo;
66
67 $todayDate = gmdate('Y-m-d'); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
68
69 $lastDate = end($ranges);
70
71 foreach ($ranges as $date) {
72 $day = strtolower(gmdate('D', strtotime($date))); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
73
74 $availableSlots = $daySlots[$day] ?? [];
75
76 $availableSlots = $this->maybeDateOverrides($dateOverrides, $availableSlots, $date, $duration);
77
78 if (!$availableSlots) {
79 continue;
80 }
81
82 $isToday = $date === $todayDate;
83
84 $isLastDay = $date === $lastDate;
85
86 $validSlots = [];
87
88 $validDstDateSlots = [];
89
90 foreach ($availableSlots as $start) {
91 $end = gmdate('H:i', strtotime($start) + $period); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
92 $endDate = $start < $end ? $date : gmdate('Y-m-d', strtotime($date) + 86400); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
93
94 $slot = [
95 'start' => $date . ' ' . $start . ':00',
96 'end' => $endDate . ' ' . $end . ':00'
97 ];
98
99 $slot = $this->maybeDayLightSavingSlot($slot, $dstTime, $scheduleTimezone);
100
101 $slotDate = gmdate('Y-m-d', strtotime($slot['start'])); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
102
103 $currentBookedSlots = $bookedSlots[$slotDate] ?? [];
104
105 if ($isToday && strtotime($slot['start']) < $cutOutTime) {
106 continue;
107 }
108
109 if ($isLastDay && strtotime($slot['end']) > $maxBookingTime) {
110 break;
111 }
112
113 $isSlotAvailable = $this->isSlotAvailable($slot, $currentBookedSlots, $bufferTime, $hostId);
114
115 if ($isSlotAvailable) {
116 if ($slotDate != $date) {
117 $validDstDateSlots[] = $slot;
118 } else {
119 $validSlots[] = $slot;
120 }
121 }
122 }
123
124 if ($validSlots) {
125 $currentSlots = $rangedSlots[$date] ?? [];
126 $rangedSlots[$date] = $this->mergeAndSortSlots($currentSlots, $validSlots);
127 }
128
129 if ($validDstDateSlots) {
130 $slotDate = gmdate('Y-m-d', strtotime($validDstDateSlots[0]['start'])); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
131 $currentSlots = $rangedSlots[$slotDate] ?? [];
132 $rangedSlots[$slotDate] = $this->mergeAndSortSlots($currentSlots, $validDstDateSlots);
133 }
134 }
135
136 return $rangedSlots;
137 }
138
139 protected function isSlotAvailable(&$slot, $currentBookedSlots, $bufferTime, $hostId)
140 {
141 if (!$currentBookedSlots) {
142 return true;
143 }
144
145 $startTimeStamp = strtotime($slot['start']);
146 $endTimeStamp = strtotime($slot['end']);
147
148 foreach ($currentBookedSlots as $bookedSlot) {
149 $bookedStart = strtotime($bookedSlot['start']);
150 $bookedEnd = strtotime($bookedSlot['end']);
151
152 if (Arr::get($bookedSlot, 'source')) {
153 $bookedStart = $bookedStart - $bufferTime;
154 $bookedEnd = $bookedEnd + $bufferTime;
155 }
156
157 if (
158 ($startTimeStamp >= $bookedStart && $startTimeStamp < $bookedEnd) ||
159 ($endTimeStamp > $bookedStart && $endTimeStamp <= $bookedEnd) ||
160 ($startTimeStamp <= $bookedStart && $endTimeStamp > $bookedStart) ||
161 ($startTimeStamp < $bookedEnd && $endTimeStamp >= $bookedEnd)
162 ) {
163 if (!Arr::get($bookedSlot, 'remaining')) {
164 return false;
165 }
166 $slot['remaining'] = $bookedSlot['remaining'];
167 }
168 }
169
170 return true;
171 }
172
173 public function isSpotAvailable($fromTime, $toTime, $duration = null, $hostId = null)
174 {
175 $this->hostId = $hostId;
176
177 $fromTimeStamp = strtotime($fromTime);
178 $toTimeStamp = strtotime($toTime);
179
180 $duration = $this->calendarSlot->getDuration($duration);
181
182 list($scheduleTimezone, $dstTime) = $this->getTimezoneInfo();
183
184 $fromStartTime = $this->maybeDayLightSavingTime($fromTime, $dstTime, $scheduleTimezone);
185 $toEndTime = $this->maybeDayLightSavingTime($toTime, $dstTime, $scheduleTimezone);
186
187 $fromTime = gmdate('Y-m-d 00:00:00', strtotime($fromStartTime)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
188 $toTime = gmdate('Y-m-d 23:59:59', strtotime($toEndTime)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
189
190 $slots = $this->getDates($fromTime, $toTime, $duration, true);
191
192 $fromDate = gmdate('Y-m-d', $fromTimeStamp); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
193 $toDate = gmdate('Y-m-d', $toTimeStamp); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
194
195 $availableSlots = $slots[$fromDate] ?? [];
196
197 if ($fromDate != $toDate) {
198 $availableSlots = array_merge($availableSlots, $slots[$toDate] ?? []);
199 }
200
201 return $this->isSlotExists($availableSlots, $fromTimeStamp, $toTimeStamp);
202 }
203
204 protected function isSlotExists($availableSlots, $fromTimeStamp, $toTimeStamp)
205 {
206 $left = 0;
207 $right = count($availableSlots) - 1;
208
209 while ($left <= $right) {
210 $mid = $left + (($right - $left) >> 1);
211 $slot = $availableSlots[$mid];
212
213 $slotStartTime = strtotime($slot['start']);
214 $slotEndTime = strtotime($slot['end']);
215
216 if ($fromTimeStamp == $slotStartTime && $toTimeStamp == $slotEndTime) {
217 return $slot;
218 } elseif ($fromTimeStamp > $slotStartTime) {
219 $left = $mid + 1;
220 } else {
221 $right = $mid - 1;
222 }
223 }
224
225 return false;
226 }
227
228 protected function getCurrentDateRange($startDate = false, $endDate = false)
229 {
230 if (!$startDate) {
231 $startDate = gmdate('Y-m-d'); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
232 }
233
234 if (!$endDate) {
235 $endDate = gmdate('Y-m-t 23:59:59', strtotime($startDate)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
236 }
237
238 $currentDate = strtotime($startDate);
239 $endDate = strtotime($endDate) + 1; // add 1s in case end is 23:59:59
240 $oneDay = 24 * 60 * 60;
241
242 $dateArray = [];
243
244 while ($currentDate <= $endDate) {
245 $dateArray[] = gmdate('Y-m-d', $currentDate); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
246 $currentDate += $oneDay;
247 }
248
249 return $dateArray;
250 }
251
252 protected function bookSlot($eventId, $start, $end, $remaining = 0, $source = null)
253 {
254 return [
255 'event_id' => $eventId,
256 'start' => $start,
257 'end' => $end,
258 'remaining' => $remaining,
259 'source' => $source
260 ];
261 }
262
263 protected function getBookedSlots($dateRange, $toTimeZone = 'UTC', $isDoingBooking = false)
264 {
265 if ($toTimeZone != 'UTC') {
266 $dateRange[0] = DateTimeHelper::convertToUtc($dateRange[0], $toTimeZone);
267 $dateRange[1] = DateTimeHelper::convertToUtc($dateRange[1], $toTimeZone);
268 }
269
270 $hostIds = $this->calendarSlot->getHostIds($this->hostId);
271 $status = ['pending', 'reserved', 'approved', 'scheduled', 'completed'];
272
273 // Single indexed start_time range: widen the lower bound by max booking duration to catch overlaps.
274 $maxDurationMinutes = (int) apply_filters('fluent_booking/max_booking_duration_minutes', DAY_IN_SECONDS / MINUTE_IN_SECONDS, $this->calendarSlot);
275
276 $rangeLowerBound = gmdate('Y-m-d H:i:s', strtotime($dateRange[0]) - $maxDurationMinutes * MINUTE_IN_SECONDS); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
277
278 $bookings = Booking::with(['calendar_event'])
279 ->whereHas('hosts', function ($query) use ($hostIds) {
280 $query->whereIn('user_id', $hostIds);
281 })
282 ->where('start_time', '>=', $rangeLowerBound)
283 ->where('start_time', '<=', $dateRange[1])
284 ->where('end_time', '>=', $dateRange[0])
285 ->orderBy('start_time', 'ASC')
286 ->whereIn('status', $status)
287 ->get()
288 ->groupBy('group_id');
289
290 $maxBooking = $this->calendarSlot->getMaxBookingPerSlot();
291
292 $isGroupBooking = $maxBooking > 1;
293
294 $books = $this->processBookings($bookings, $toTimeZone, $maxBooking, $isGroupBooking);
295
296 $books = apply_filters('fluent_booking/local_booked_events', $books, $this->calendarSlot, $toTimeZone, $dateRange, $isDoingBooking);
297
298 $remoteBookings = apply_filters('fluent_booking/remote_booked_events', [], $this->calendarSlot, $toTimeZone, $dateRange, $this->hostId, $isDoingBooking);
299
300 $books = $this->processRemoteBookings($books, $remoteBookings);
301
302 return apply_filters('fluent_booking/booked_events', $books, $this->calendarSlot, $toTimeZone, $dateRange, $isDoingBooking);
303 }
304
305 protected function processBookings($bookings, $toTimeZone, $maxBooking, $isGroupBooking)
306 {
307 $books = [];
308 foreach ($bookings as $booking) {
309 $booked = $booking->count();
310 $booking = $booking[0];
311
312 $remaining = 0;
313 if ($this->calendarSlot->id == $booking->event_id) {
314 if ($booking->status == 'reserved') {
315 continue;
316 }
317 $remaining = max(0, $maxBooking - $booked);
318 }
319
320 if ($toTimeZone != 'UTC') {
321 $booking->start_time = DateTimeHelper::convertToTimeZone($booking->start_time, 'UTC', $toTimeZone);
322 $booking->end_time = DateTimeHelper::convertToTimeZone($booking->end_time, 'UTC', $toTimeZone);
323 }
324
325 $date = gmdate('Y-m-d', strtotime($booking->start_time)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
326
327 $books[$date] = $books[$date] ?? [];
328
329 $bufferTime = $booking->calendar_event->getTotalBufferTime();
330 if ($bufferTime) {
331 $beforeBufferTime = gmdate('Y-m-d H:i:s', strtotime($booking->start_time . " -$bufferTime minutes")); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
332 $afterBufferTime = gmdate('Y-m-d H:i:s', strtotime($booking->end_time . " +$bufferTime minutes")); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
333 if ($remaining) {
334 if ($beforeBufferTime < $booking->start_time) {
335 $books[$date][] = $this->bookSlot(null, $beforeBufferTime, $booking->start_time);
336 }
337 if ($afterBufferTime > $booking->end_time) {
338 $books[$date][] = $this->bookSlot(null, $booking->end_time, $afterBufferTime);
339 }
340 } else {
341 $booking->start_time = $beforeBufferTime;
342 $booking->end_time = $afterBufferTime;
343 }
344 }
345
346 $rangedItems = $this->createDateRangeArrayFromSlotConfig([
347 'event_id' => $booking->event_id,
348 'start' => $booking->start_time,
349 'end' => $booking->end_time,
350 'remaining' => $remaining
351 ]);
352
353 $eventIdAdded = false;
354 foreach ($rangedItems as $date => $slot) {
355 if ($isGroupBooking && $remaining && $this->calendarSlot->id == $booking->event_id) {
356 if ($eventIdAdded) {
357 $slot['event_id'] = null;
358 }
359 $eventIdAdded = true;
360 }
361 $books[$date] = $books[$date] ?? [];
362 $books[$date][] = $slot;
363 }
364 }
365
366 return $books;
367 }
368
369 protected function processRemoteBookings($books, $remoteBookings)
370 {
371 if (!$remoteBookings) {
372 return $books;
373 }
374
375 foreach ($remoteBookings as $slot) {
376 $rangedItems = $this->createDateRangeArrayFromSlotConfig([
377 'start' => $slot['start'],
378 'end' => $slot['end'],
379 'source' => $slot['source']
380 ]);
381
382 foreach ($rangedItems as $rangedDate => $rangedSlot) {
383 $books[$rangedDate] = $books[$rangedDate] ?? [];
384
385 if (!$this->isLocalBooking($books[$rangedDate], $rangedSlot)) {
386 $books[$rangedDate][] = $rangedSlot;
387 }
388 }
389 }
390 return $books;
391 }
392
393 protected function getWeekDaySlots($duration, $hostId = null)
394 {
395 $period = $duration * 60;
396
397 $hostId = $hostId ?: $this->hostId;
398
399 $interval = $this->calendarSlot->getSlotInterval($duration) * 60;
400
401 $weeklySlots = $this->calendarSlot->getWeeklySlots($hostId);
402
403 $items = $this->getEnabledSlots($weeklySlots);
404
405 // create range of each day slots from $items array above with $period minutes interval
406 $formattedSlots = [];
407 $days = array_keys($items);
408 foreach ($items as $day => &$slots) {
409 $daySlots = [];
410 foreach ($slots as $slot) {
411 $slot['end'] = ($slot['end'] == '00:00') ? '24:00' : $slot['end'];
412 $start = strtotime($slot['start']);
413 $end = strtotime($slot['end']);
414
415 while ($start + $period <= $end) {
416 $daySlots[] = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
417 $start += $interval;
418 }
419
420 if ($slot['end'] == '24:00' && $start < $end) {
421 $daySlots = $this->handleNextDaySlot($daySlots, $items, $start, $end, $interval, $period, $day, $days);
422 }
423 }
424 if ($daySlots) {
425 $formattedSlots[$day] = $daySlots;
426 }
427 }
428
429 return $formattedSlots;
430 }
431
432 private function getEnabledSlots($weeklySlots)
433 {
434 $items = [];
435
436 foreach ($weeklySlots as $weekDay => $weeklySlot) {
437 if ($weeklySlot['enabled'] || !empty($weeklySlot['slots'])) {
438 $items[$weekDay] = $weeklySlot['slots'];
439 }
440 }
441
442 return $items;
443 }
444
445 protected function handleNextDaySlot($daySlots, &$items, $start, $end, $interval, $period, $day, $days)
446 {
447 $nextDayIndex = (array_search($day, $days) + 1) % count($items);
448
449 if (isset($days[$nextDayIndex])) {
450 $nextDay = $items[$days[$nextDayIndex]];
451
452 if ($nextDay && $nextDay[0]['start'] == '00:00') {
453 $nextDayEndTime = strtotime($nextDay[0]['end']) - strtotime($nextDay[0]['start']);
454 $reserveTime = $end - $start;
455
456 while ($period - $reserveTime <= $nextDayEndTime) {
457 $startTime = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
458 $nextDayStart = gmdate('H:i', $interval - $reserveTime); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
459 $daySlots[] = $startTime;
460
461 if ($nextDayStart < $startTime) {
462 $items[$days[$nextDayIndex]][0]['start'] = $nextDayStart;
463 break;
464 }
465
466 $start += $interval;
467 $reserveTime = $end - $start;
468 }
469 }
470 }
471
472 return $daySlots;
473 }
474
475 protected function maybeDateOverrides($dateOverrides, $availableSlots, $date, $duration)
476 {
477 if (!$dateOverrides) {
478 return $availableSlots;
479 }
480
481 list($overrideSlots, $overrideDays) = $dateOverrides;
482
483 if ($overrideDays && isset($overrideDays[$date])) {
484 $availableSlots = $this->removeOverrideSlots($availableSlots, $overrideDays[$date]);
485 }
486
487 if ($overrideSlots && isset($overrideSlots[$date])) {
488 $flatOverrideSlots = $this->convertSlotSetsToFlat($overrideSlots, $date, $duration);
489 $availableSlots = array_merge($availableSlots, $flatOverrideSlots);
490 $availableSlots = $this->sortDaySlots($availableSlots);
491 }
492
493 return $availableSlots;
494 }
495
496 protected function convertSlotSetsToFlat(&$overrideSlots, $date, $duration = null)
497 {
498 $period = $this->calendarSlot->getDuration($duration) * 60;
499
500 $interval = $this->calendarSlot->getSlotInterval($duration) * 60;
501
502 $formattedSlots = [];
503
504 $slotSets = $overrideSlots[$date];
505
506 foreach ($slotSets as $slot) {
507 $slot['end'] = ($slot['end'] == '00:00') ? '24:00' : $slot['end'];
508 $start = strtotime($slot['start']);
509 $end = strtotime($slot['end']);
510
511 while ($start + $period <= $end) {
512 $formattedSlots[] = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
513 $start += $interval;
514 }
515
516 if ($slot['end'] == '24:00' && $start < $end) {
517 $formattedSlots = $this->handleNextDayOverrideSlot($formattedSlots, $start, $end, $interval, $period, $overrideSlots, $date);
518 }
519 }
520
521 return $formattedSlots;
522 }
523
524 protected function handleNextDayOverrideSlot($formattedSlots, $start, $end, $interval, $period, &$overrideSlots, $date)
525 {
526 $nextDayIndex = gmdate('Y-m-d', strtotime($date) + 86400); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
527
528 if (isset($overrideSlots[$nextDayIndex])) {
529 $nextDay = $overrideSlots[$nextDayIndex];
530
531 if ($nextDay && $nextDay[0]['start'] == '00:00') {
532 $nextDayEndTime = strtotime($nextDay[0]['end']) - strtotime($nextDay[0]['start']);
533 $reserveTime = $end - $start;
534
535 while ($period - $reserveTime <= $nextDayEndTime) {
536 $startTime = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
537 $nextDayStart = gmdate('H:i', $interval - $reserveTime); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
538 $formattedSlots[] = $startTime;
539
540 if ($startTime > $nextDayStart) {
541 $overrideSlots[$nextDayIndex][0]['start'] = $nextDayStart;
542 break;
543 }
544
545 $start += $interval;
546 $reserveTime = $end - $start;
547 }
548 }
549 }
550
551 return $formattedSlots;
552 }
553
554 protected function removeOverrideSlots($availableSlots, $overrideDay)
555 {
556 if (!$availableSlots || !$overrideDay) {
557 return $availableSlots;
558 }
559
560 $startTime = strtotime($overrideDay['start']);
561 $endTime = strtotime($overrideDay['end']);
562
563 $filteredSlots = array_filter($availableSlots, function ($slot) use ($startTime, $endTime) {
564 return strtotime($slot) < $startTime || strtotime($slot) >= $endTime;
565 });
566
567 return $filteredSlots;
568 }
569
570 public function getAvailableSpots($startDate, $timeZone = 'UTC', $duration = null, $hostId = null)
571 {
572 $this->hostId = $hostId;
573
574 $event = $this->calendarSlot;
575 $duration = $event->getDuration($duration);
576
577 $adjustedDate = $this->adjustStartDate($startDate, $timeZone);
578
579 $isDisplaySpots = $event->is_display_spots;
580 $isMultiGuest = $event->isMultiGuestEvent();
581 $isMultiBooking = $event->isAdditionalGuestEnabled();
582 $endDate = $event->getMaxBookableDateTime($adjustedDate, $timeZone);
583 $startDate = $event->getMinBookableDateTime($startDate, $timeZone);
584
585 $maxBooking = false;
586 if ($isMultiGuest && ($isDisplaySpots || $isMultiBooking)) {
587 $maxBooking = $event->getMaxBookingPerSlot();
588 }
589
590 if (strtotime($startDate) > strtotime($endDate)) {
591 return new \WP_Error('invalid_date_range', __('Invalid date range', 'fluent-booking'));
592 }
593
594 $slots = $this->getDates($startDate, $endDate, $duration, false, $timeZone);
595
596 return $this->convertSpots($slots, $startDate, 'UTC', $timeZone, $maxBooking);
597 }
598
599 protected function adjustStartDate($startDate, $timeZone)
600 {
601 $requestedDate = $startDate;
602
603 $startDate = DateTimeHelper::convertToUtc($startDate, $timeZone);
604 $currentDateTime = gmdate('Y-m-d H:i:s'); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
605
606 if (strtotime($startDate) < strtotime($currentDateTime)) {
607 $startDate = $currentDateTime;
608 }
609
610 // Extract month and year from the timezone converted start date and requested date
611 list($startDateMonth, $startDateYear) = $this->extractMonthAndYear($startDate);
612 list($requestedDateMonth, $requestedDateYear) = $this->extractMonthAndYear($requestedDate);
613
614 if ($startDateYear < $requestedDateYear || $startDateMonth < $requestedDateMonth) {
615 $startDate = gmdate('Y-m-01 00:00:00', strtotime($requestedDate)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
616 }
617
618 return $startDate;
619 }
620
621 protected function convertSpots($slots, $startDate, $fromTimeZone = 'UTC', $toTimeZone = 'UTC', $maxBooking = false)
622 {
623 $minBookableTimestamp = strtotime($startDate);
624
625 $convertedSpots = [];
626 foreach ($slots as $spots) {
627 foreach ($spots as $spot) {
628 $start = $spot['start'];
629 $end = $spot['end'];
630
631 if (strtotime($start) < $minBookableTimestamp) {
632 continue;
633 }
634
635 if ($fromTimeZone != $toTimeZone) {
636 $start = DateTimeHelper::convertToTimeZone($spot['start'], 'UTC', $toTimeZone);
637 $end = DateTimeHelper::convertToTimeZone($spot['end'], 'UTC', $toTimeZone);
638 }
639
640 $spotStartDate = gmdate('Y-m-d', strtotime($start)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
641
642 $convertedSpots[$spotStartDate] = $convertedSpots[$spotStartDate] ?? [];
643
644 $remainingSlots = $maxBooking ? Arr::get($spot, 'remaining', $maxBooking) : false;
645
646 $convertedSpots[$spotStartDate][$start] = [
647 'start' => $start,
648 'end' => $end,
649 'remaining' => $remainingSlots,
650 ];
651 }
652 }
653
654 $convertedSpots = array_map(function ($spots) {
655 return array_values(ksort($spots) ? $spots : $spots);
656 }, $convertedSpots);
657
658 return $convertedSpots;
659 }
660
661 private function extractMonthAndYear($date)
662 {
663 $month = gmdate('m', strtotime($date)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
664 $year = gmdate('Y', strtotime($date)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
665 return [$month, $year];
666 }
667
668 protected function createDateRangeArrayFromSlotConfig($slotConfig = [])
669 {
670 if (empty($slotConfig['start']) || empty($slotConfig['end'])) {
671 return [];
672 }
673
674 $startTime = $slotConfig['start'];
675 $endTime = $slotConfig['end'];
676 if (gmdate('Ymd', strtotime($startTime)) == gmdate('Ymd', strtotime($endTime))) {
677 return [
678 gmdate('Y-m-d', strtotime($startTime)) => $this->bookSlot(Arr::get($slotConfig, 'event_id'), $startTime, $endTime, Arr::get($slotConfig, 'remaining'), Arr::get($slotConfig, 'source'))
679 ];
680 }
681
682 $start = new \DateTime($startTime);
683 $end = new \DateTime($endTime);
684
685 // Set the end time to the end of the day if it's set to the beginning of a day
686 if ($end->format('H:i:s') === '00:00:00') {
687 $end->modify('-1 second'); // This will set the time to 23:59:59 of the previous day
688 }
689
690 $interval = new \DateInterval('P1D');
691 $dateRange = new \DatePeriod($start, $interval, $end);
692
693 $rangeArray = [];
694 foreach ($dateRange as $date) {
695 $dateKey = $date->format('Y-m-d');
696
697 if ($date->format('Y-m-d') === $start->format('Y-m-d')) {
698 $rangeArray[$dateKey] = $this->bookSlot(Arr::get($slotConfig, 'event_id'), $startTime, $date->format('Y-m-d 23:59:59'), Arr::get($slotConfig, 'remaining'), Arr::get($slotConfig, 'source'));
699 } elseif ($date->format('Y-m-d') === $end->format('Y-m-d')) {
700 $rangeArray[$dateKey] = $this->bookSlot(Arr::get($slotConfig, 'event_id'), $date->format('Y-m-d 00:00:00'), $endTime, Arr::get($slotConfig, 'remaining'), Arr::get($slotConfig, 'source'));
701 } else {
702 $rangeArray[$dateKey] = $this->bookSlot(Arr::get($slotConfig, 'event_id'), $date->format('Y-m-d 00:00:00'), $date->format('Y-m-d 23:59:59'), Arr::get($slotConfig, 'remaining'), Arr::get($slotConfig, 'source'));
703 }
704 }
705
706 // Add the last day if it was not included in the loop
707 if ($end->format('Y-m-d') !== $start->format('Y-m-d')) {
708 $lastDayKey = $end->format('Y-m-d');
709 $rangeArray[$lastDayKey] = $this->bookSlot(Arr::get($slotConfig, 'event_id'), $end->format('Y-m-d 00:00:00'), $endTime, Arr::get($slotConfig, 'remaining'), Arr::get($slotConfig, 'source'));
710 }
711
712 return $rangeArray;
713 }
714
715 private function maybeBookingFrequencyLimitRanges($ranges)
716 {
717 if (!$ranges) {
718 return $ranges;
719 }
720
721 $isBookingFrequencyEnabled = !!Arr::get($this->calendarSlot->settings, 'booking_frequency.enabled');
722 if (!$isBookingFrequencyEnabled) {
723 return $ranges;
724 }
725
726 $keyedFrequenceyLimits = [];
727 $frequenceyLimits = Arr::get($this->calendarSlot->settings, 'booking_frequency.limits', []);
728 foreach ($frequenceyLimits as $limit) {
729 if (!empty($limit['value'])) {
730 $keyedFrequenceyLimits[$limit['unit']] = $limit['value'];
731 }
732 }
733
734 // Per Month Booking Frequency Limit Hanlder
735 if (!empty($keyedFrequenceyLimits['per_month'])) {
736 $startDate = gmdate('Y-m-01 00:00:00', strtotime(min($ranges)));
737 $endDate = gmdate('Y-m-t 23:59:59', strtotime(min($ranges)));
738
739 $monthlyLimit = (int)$keyedFrequenceyLimits['per_month'];
740
741 $monthlyCount = $this->getBookingsTotal($startDate, $endDate);
742
743 if ($monthlyCount >= $monthlyLimit) {
744 return [];
745 }
746 }
747
748 // Per Week Booking Frequency Limit Hanlder
749 if (!empty($keyedFrequenceyLimits['per_week'])) {
750
751 if (!$ranges) {
752 return [];
753 }
754
755 $weeklyLimit = (int)$keyedFrequenceyLimits['per_week'];
756 $filledWeeks = $this->getFilledWeeks(min($ranges), max($ranges));
757 foreach ($filledWeeks as $filledWeek) {
758
759 $weeklyCount = $this->getBookingsTotal($filledWeek[0] . ' 00:00:00', $filledWeek[6] . ' 23:59:59');
760
761 if ($weeklyCount >= $weeklyLimit) {
762 $ranges = array_filter($ranges, function ($rangeDate) use ($filledWeek) {
763 return !in_array($rangeDate, $filledWeek);
764 });
765
766 if (!$ranges) {
767 return [];
768 }
769 }
770 }
771 }
772 return $ranges;
773 }
774
775 private function maybeBookingDurationLimitRanges($ranges, $duration)
776 {
777 if (!$ranges) {
778 return $ranges;
779 }
780
781 if (!Arr::get($this->calendarSlot->settings, 'booking_duration.enabled')) {
782 return $ranges;
783 }
784
785 $limits = Arr::get($this->calendarSlot->settings, 'booking_duration.limits', []);
786
787 $keyedLimits = [];
788 foreach ($limits as $limit) {
789 if (!empty($limit['value'])) {
790 $keyedLimits[$limit['unit']] = (int)$limit['value'];
791 }
792 }
793
794 // Per Month Booking Frequency Limit Hanlder
795 if (!empty($keyedLimits['per_month'])) {
796 $startDate = gmdate('Y-m-01 00:00:00', strtotime(min($ranges)));
797 $endDate = gmdate('Y-m-t 23:59:59', strtotime(min($ranges)));
798
799 $monthlyDuration = $this->getBookingDurationTotal($startDate, $endDate);
800
801 if ($monthlyDuration + $duration > $keyedLimits['per_month']) {
802 $ranges = [];
803 }
804 }
805
806 // Per Week Booking Frequency Limit Hanlder
807 if (!empty($keyedLimits['per_week'])) {
808 $weeklyLimit = (int)$keyedLimits['per_week'];
809 $filledWeeks = $this->getFilledWeeks(min($ranges), max($ranges));
810 foreach ($filledWeeks as $filledWeek) {
811 $weeklyDuration = $this->getBookingDurationTotal($filledWeek[0] . ' 00:00:00', $filledWeek[6] . ' 23:59:59');
812
813 if ($weeklyDuration + $duration > $weeklyLimit) {
814 $ranges = array_filter($ranges, function ($rangeDate) use ($filledWeek) {
815 return !in_array($rangeDate, $filledWeek);
816 });
817
818 if (!$ranges) {
819 return [];
820 }
821 }
822 }
823 }
824 return $ranges;
825 }
826
827 protected function maybeBookingPerDayLimitSlots($rangesSlots, $bookedSlots, $duration)
828 {
829 $isDurationEnabled = !!Arr::get($this->calendarSlot->settings, 'booking_duration.enabled');
830
831 $isFrequencyEnabled = !!Arr::get($this->calendarSlot->settings, 'booking_frequency.enabled');
832
833 if (!$isDurationEnabled && !$isFrequencyEnabled) {
834 return $rangesSlots;
835 }
836
837 $hostTimeZone = $this->calendarSlot->getScheduleTimezone($this->hostId);
838
839 $convertedRangesSlots = $this->convertSlotsByTimezone($rangesSlots, 'UTC', $hostTimeZone);
840
841 $convertedBookedSlots = $this->convertSlotsByTimezone($bookedSlots, 'UTC', $hostTimeZone);
842
843 $convertedRangesSlots = $this->maybeBookingDurationDayLimit($convertedRangesSlots, $convertedBookedSlots, $duration, $isDurationEnabled);
844
845 $convertedRangesSlots = $this->maybeBookingFrequencyDayLimit($convertedRangesSlots, $convertedBookedSlots, $isFrequencyEnabled);
846
847 $rangesSlots = $this->convertSlotsByTimezone($convertedRangesSlots, $hostTimeZone, 'UTC');
848
849 return $rangesSlots;
850 }
851
852 protected function maybeBookingDurationDayLimit($rangesSlots, $bookedSlots, $duration, $isEnabled)
853 {
854 if (!$isEnabled) {
855 return $rangesSlots;
856 }
857
858 $limits = Arr::get($this->calendarSlot->settings, 'booking_duration.limits', []);
859
860 $perDayLimit = null;
861 foreach ($limits as $limit) {
862 if (Arr::get($limit, 'unit') == 'per_day' && Arr::get($limit, 'value')) {
863 $perDayLimit = (int)Arr::get($limit, 'value');
864 break;
865 }
866 }
867
868 if (!$perDayLimit) {
869 return $rangesSlots;
870 }
871
872 $isMultiSlot = $this->calendarSlot->isMultiGuestEvent();
873
874 foreach ($rangesSlots as $rangeDate => &$slots) {
875 if (!isset($bookedSlots[$rangeDate])) {
876 continue;
877 }
878
879 $dayDuration = array_reduce($bookedSlots[$rangeDate], function ($carry, $slot) {
880 if (Arr::get($slot, 'event_id') == $this->calendarSlot->id) {
881 $carry += (int)((strtotime($slot['end']) - strtotime($slot['start'])) / 60);
882 }
883 return $carry;
884 }, 0);
885
886 if ($dayDuration + $duration > $perDayLimit) {
887 if ($isMultiSlot) {
888 $slots = array_values(array_filter($slots, function ($slot) {
889 return !empty(Arr::get($slot, 'remaining'));
890 }));
891 }
892 if (!$isMultiSlot || !count($slots)) {
893 unset($rangesSlots[$rangeDate]);
894 }
895 }
896 }
897
898 return $rangesSlots;
899 }
900
901 protected function maybeBookingFrequencyDayLimit($rangesSlots, $bookedSlots, $isEnabled)
902 {
903 if (!$isEnabled) {
904 return $rangesSlots;
905 }
906
907 $limits = Arr::get($this->calendarSlot->settings, 'booking_frequency.limits', []);
908
909 $perDayLimit = null;
910 foreach ($limits as $limit) {
911 if (Arr::get($limit, 'unit') == 'per_day' && Arr::get($limit, 'value')) {
912 $perDayLimit = (int)Arr::get($limit, 'value');
913 break;
914 }
915 }
916
917 if (!$perDayLimit) {
918 return $rangesSlots;
919 }
920
921 $isMultiSlot = $this->calendarSlot->isMultiGuestEvent();
922
923 foreach ($rangesSlots as $rangeDate => &$slots) {
924 if (!isset($bookedSlots[$rangeDate])) {
925 continue;
926 }
927
928 $dayBooked = array_filter($bookedSlots[$rangeDate], function ($slot) {
929 return Arr::get($slot, 'event_id') == $this->calendarSlot->id;
930 });
931
932 if (count($dayBooked) >= $perDayLimit) {
933 if ($isMultiSlot) {
934 $slots = array_values(array_filter($slots, function ($slot) {
935 return !empty(Arr::get($slot, 'remaining'));
936 }));
937 }
938 if (!$isMultiSlot || !count($slots)) {
939 unset($rangesSlots[$rangeDate]);
940 }
941 }
942 }
943
944 return $rangesSlots;
945 }
946
947 protected function convertSlotsByTimezone($slots, $fromTimeZone, $toTimeZone)
948 {
949 if ($fromTimeZone == $toTimeZone) {
950 return $slots;
951 }
952
953 $convertedSlots = [];
954
955 foreach ($slots as $spots) {
956 foreach ($spots as $spot) {
957 $spot['start'] = DateTimeHelper::convertToTimeZone($spot['start'], $fromTimeZone, $toTimeZone);
958 $spot['end'] = DateTimeHelper::convertToTimeZone($spot['end'], $fromTimeZone, $toTimeZone);
959
960 $spotDate = gmdate('Y-m-d', strtotime($spot['start']));
961
962 $convertedSlots[$spotDate] = $convertedSlots[$spotDate] ?? [];
963
964 $convertedSlots[$spotDate][] = $spot;
965 }
966 }
967
968 return $convertedSlots;
969 }
970
971 public function getFilledWeeks($from, $to, $weekStart = '')
972 {
973 $weekStart = $weekStart ? $weekStart : Arr::get(Helper::getGlobalSettings(), 'administration.start_day', 'sun');
974
975 $startDate = new DateTime($from);
976 $endDate = new DateTime($to);
977
978 if (strtolower($startDate->format('D')) != $weekStart) {
979 $startDate->modify('last ' . $weekStart);
980 }
981
982 $weeks = [];
983
984 while ($startDate <= $endDate) {
985 // get all days in this week
986 $week = [];
987 for ($i = 0; $i < 7; $i++) {
988 $week[] = $startDate->format('Y-m-d');
989 $startDate->modify('+1 day');
990 }
991 $weeks[] = $week;
992 }
993
994 return $weeks;
995 }
996
997 protected function getBookingsTotal($start, $end)
998 {
999 if ($this->calendarSlot->event_type == 'group') {
1000 return Booking::query()
1001 ->where('event_id', $this->calendarSlot->id)
1002 ->whereBetween('start_time', [$start, $end])
1003 ->whereIn('status', ['scheduled', 'completed'])
1004 ->groupBy('group_id')
1005 ->count();
1006 }
1007
1008 return Booking::query()
1009 ->where('event_id', $this->calendarSlot->id)
1010 ->whereBetween('start_time', [$start, $end])
1011 ->whereIn('status', ['scheduled', 'completed'])
1012 ->count();
1013 }
1014
1015 protected function getBookingDurationTotal($start, $end)
1016 {
1017 if ($this->calendarSlot->event_type == 'group') {
1018 return Booking::query()
1019 ->select(['group_id', 'slot_minutes'])
1020 ->where('event_id', $this->calendarSlot->id)
1021 ->whereBetween('start_time', [$start, $end])
1022 ->whereIn('status', ['scheduled', 'completed'])
1023 ->groupBy('group_id')
1024 ->get()
1025 ->sum('slot_minutes');
1026 }
1027
1028 return Booking::query()
1029 ->where('event_id', $this->calendarSlot->id)
1030 ->whereBetween('start_time', [$start, $end])
1031 ->whereIn('status', ['scheduled', 'completed'])
1032 ->sum('slot_minutes');
1033 }
1034
1035 protected function getMaxBookingTimestamp($fromDate, $toDate, $timeZone)
1036 {
1037 $maxBookingTime = $this->calendarSlot->getMaxBookableDateTime($toDate, $timeZone, 'Y-m-d H:i:s');
1038
1039 return strtotime($maxBookingTime);
1040 }
1041
1042 protected function getTimezoneInfo($hostId = null)
1043 {
1044 $hostId = $hostId ?: $this->hostId;
1045
1046 $scheduleTimezone = $this->calendarSlot->getScheduleTimezone($hostId);
1047
1048 $dstTime = DateTimeHelper::getDaylightSavingTime($scheduleTimezone);
1049
1050 return [$scheduleTimezone, $dstTime];
1051 }
1052
1053 protected function maybeDayLightSavingSlot($slot, $dstTime, $scheduleTimezone, $adjustSign = '-')
1054 {
1055 if (!$dstTime) {
1056 return $slot;
1057 }
1058
1059 $slot['start'] = $this->maybeDayLightSavingTime($slot['start'], $dstTime, $scheduleTimezone, $adjustSign);
1060 $slot['end'] = $this->maybeDayLightSavingTime($slot['end'], $dstTime, $scheduleTimezone, $adjustSign);
1061
1062 return $slot;
1063 }
1064
1065 protected function maybeDayLightSavingTime($time, $dstTime, $timezone, $adjustSign = '+')
1066 {
1067 $scheduleTime = DateTimeHelper::convertToTimeZone($time, 'UTC', $timezone);
1068 if (DateTimeHelper::isDaylightSavingActive($scheduleTime, $timezone)) {
1069 $time = gmdate('Y-m-d H:i:s', strtotime($time . " $adjustSign $dstTime minutes")); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1070 }
1071
1072 return $time;
1073 }
1074
1075 protected function isLocalBooking($bookings, $slot)
1076 {
1077 if (empty($bookings)) {
1078 return false;
1079 }
1080
1081 foreach ($bookings as $book) {
1082 if ($book['start'] == $slot['start'] && $book['end'] == $slot['end']) {
1083 return true;
1084 }
1085 }
1086
1087 return false;
1088 }
1089
1090 protected function mergeAndSortSlots($currentSlots, $validSlots)
1091 {
1092 if (!$currentSlots) {
1093 return $validSlots;
1094 }
1095
1096 $mergedSlots = array_merge($currentSlots, $validSlots);
1097
1098 usort($mergedSlots, function ($a, $b) {
1099 return strtotime($a['start']) - strtotime($b['start']);
1100 });
1101
1102 return $mergedSlots;
1103 }
1104
1105 protected function sortDaySlots($daySlots)
1106 {
1107 usort($daySlots, function ($a, $b) {
1108 return strtotime($a) - strtotime($b);
1109 });
1110
1111 return $daySlots;
1112 }
1113 }
1114