PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 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 All 34 releases
fluent-booking / app / Services / TimeSlotService.php

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

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