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
← All changes | app/Services/TimeSlotService.php +170 -68 1.5.21 → 2.5.0 View file →
@@ -4,9 +4,8 @@
4 4
5 5 use FluentBooking\App\Models\Booking;
6 6 use FluentBooking\App\Models\Calendar;
7 7 use FluentBooking\App\Models\CalendarSlot;
8 -use FluentBooking\App\Models\Availability;
9 8 use FluentBooking\Framework\Support\Arr;
10 9 use FluentBooking\Framework\Support\DateTime;
11 10
12 11 class TimeSlotService
@@ -34,10 +33,10 @@
34 33 $ranges = $this->getCurrentDateRange($fromDate, $toDate);
35 34
36 35 $bookedSlots = $this->getBookedSlots([$fromDate, $toDate], 'UTC', $isDoingBooking);
37 36
38 - $ranges = $this->maybeBookingFrequencyLimitRanges($ranges, $bookedSlots);
39 - $ranges = $this->maybeBookingDurationLimitRanges($ranges, $bookedSlots, $duration);
37 + $ranges = $this->maybeBookingFrequencyLimitRanges($ranges);
38 + $ranges = $this->maybeBookingDurationLimitRanges($ranges, $duration);
40 39
41 40 $cutOutTime = DateTimeHelper::getTimestamp() + $this->calendarSlot->getCutoutSeconds();
42 41
43 42 $maxBookingTime = $this->getMaxBookingTimestamp($fromDate, $toDate, $timeZone);
@@ -45,8 +44,10 @@
45 44 $timezoneInfo = $this->getTimezoneInfo();
46 45
47 46 $rangedSlots = $this->getRangedValidSlots($ranges, $duration, $bookedSlots, $cutOutTime, $maxBookingTime, $timezoneInfo);
48 47
48 + $rangedSlots = $this->maybeBookingPerDayLimitSlots($rangedSlots, $bookedSlots, $duration);
49 +
49 50 return $rangedSlots;
50 51 }
51 52
52 53 protected function getRangedValidSlots($ranges, $duration, $bookedSlots, $cutOutTime, $maxBookingTime, $timezoneInfo, $rangedSlots = [], $hostId = null)
@@ -105,9 +106,9 @@
105 106 continue;
106 107 }
107 108
108 109 if ($isLastDay && strtotime($slot['end']) > $maxBookingTime) {
109 - continue;
110 + break;
110 111 }
111 112
112 113 $isSlotAvailable = $this->isSlotAvailable($slot, $currentBookedSlots, $bufferTime, $hostId);
113 114
@@ -179,12 +180,12 @@
179 180 $duration = $this->calendarSlot->getDuration($duration);
180 181
181 182 list($scheduleTimezone, $dstTime) = $this->getTimezoneInfo();
182 183
183 - $fromStartTime = $this->maybeDayLightSavingTime($fromTime, $dstTime, $scheduleTimezone);
184 184 $toEndTime = $this->maybeDayLightSavingTime($toTime, $dstTime, $scheduleTimezone);
185 185
186 - $fromTime = gmdate('Y-m-d 00:00:00', strtotime($fromStartTime)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
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
187 188 $toTime = gmdate('Y-m-d 23:59:59', strtotime($toEndTime)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
188 189
189 190 $slots = $this->getDates($fromTime, $toTime, $duration, true);
190 191
@@ -201,8 +202,15 @@
201 202 }
202 203
203 204 protected function isSlotExists($availableSlots, $fromTimeStamp, $toTimeStamp)
204 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 +
205 213 $left = 0;
206 214 $right = count($availableSlots) - 1;
207 215
208 216 while ($left <= $right) {
@@ -234,9 +242,9 @@
234 242 $endDate = gmdate('Y-m-t 23:59:59', strtotime($startDate)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
235 243 }
236 244
237 245 $currentDate = strtotime($startDate);
238 - $endDate = strtotime($endDate);
246 + $endDate = strtotime($endDate) + 1; // add 1s in case end is 23:59:59
239 247 $oneDay = 24 * 60 * 60;
240 248
241 249 $dateArray = [];
242 250
@@ -268,16 +276,20 @@
268 276
269 277 $hostIds = $this->calendarSlot->getHostIds($this->hostId);
270 278 $status = ['pending', 'reserved', 'approved', 'scheduled', 'completed'];
271 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 +
272 285 $bookings = Booking::with(['calendar_event'])
273 286 ->whereHas('hosts', function ($query) use ($hostIds) {
274 287 $query->whereIn('user_id', $hostIds);
275 288 })
276 - ->where(function ($query) use ($dateRange) {
277 - $query->whereBetween('start_time', $dateRange)
278 - ->orWhereBetween('end_time', $dateRange);
279 - })
289 + ->where('start_time', '>=', $rangeLowerBound)
290 + ->where('start_time', '<=', $dateRange[1])
291 + ->where('end_time', '>=', $dateRange[0])
280 292 ->orderBy('start_time', 'ASC')
281 293 ->whereIn('status', $status)
282 294 ->get()
283 295 ->groupBy('group_id');
@@ -393,9 +405,9 @@
393 405
394 406 $interval = $this->calendarSlot->getSlotInterval($duration) * 60;
395 407
396 408 $weeklySlots = $this->calendarSlot->getWeeklySlots($hostId);
397 -
409 +
398 410 $items = $this->getEnabledSlots($weeklySlots);
399 411
400 412 // create range of each day slots from $items array above with $period minutes interval
401 413 $formattedSlots = [];
@@ -444,12 +456,12 @@
444 456 if (isset($days[$nextDayIndex])) {
445 457 $nextDay = $items[$days[$nextDayIndex]];
446 458
447 459 if ($nextDay && $nextDay[0]['start'] == '00:00') {
448 - $nextDayEnd = strtotime($nextDay[0]['end']);
460 + $nextDayEndTime = strtotime($nextDay[0]['end']) - strtotime($nextDay[0]['start']);
449 461 $reserveTime = $end - $start;
450 462
451 - while ($period - $reserveTime <= $nextDayEnd) {
463 + while ($period - $reserveTime <= $nextDayEndTime) {
452 464 $startTime = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
453 465 $nextDayStart = gmdate('H:i', $interval - $reserveTime); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
454 466 $daySlots[] = $startTime;
455 467
@@ -523,12 +535,12 @@
523 535 if (isset($overrideSlots[$nextDayIndex])) {
524 536 $nextDay = $overrideSlots[$nextDayIndex];
525 537
526 538 if ($nextDay && $nextDay[0]['start'] == '00:00') {
527 - $nextDayEnd = strtotime($nextDay[0]['end']);
539 + $nextDayEndTime = strtotime($nextDay[0]['end']) - strtotime($nextDay[0]['start']);
528 540 $reserveTime = $end - $start;
529 541
530 - while ($period - $reserveTime <= $nextDayEnd) {
542 + while ($period - $reserveTime <= $nextDayEndTime) {
531 543 $startTime = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
532 544 $nextDayStart = gmdate('H:i', $interval - $reserveTime); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
533 545 $formattedSlots[] = $startTime;
534 546
@@ -566,17 +578,16 @@
566 578 {
567 579 $this->hostId = $hostId;
568 580
569 581 $event = $this->calendarSlot;
570 - $calendar = $this->calendar;
571 582 $duration = $event->getDuration($duration);
572 583
573 - $startDate = $this->adjustStartDate($startDate, $timeZone);
584 + $adjustedDate = $this->adjustStartDate($startDate, $timeZone);
574 585
575 586 $isDisplaySpots = $event->is_display_spots;
576 587 $isMultiGuest = $event->isMultiGuestEvent();
577 588 $isMultiBooking = $event->isAdditionalGuestEnabled();
578 - $endDate = $event->getMaxBookableDateTime($startDate, $timeZone);
589 + $endDate = $event->getMaxBookableDateTime($adjustedDate, $timeZone);
579 590 $startDate = $event->getMinBookableDateTime($startDate, $timeZone);
580 591
581 592 $maxBooking = false;
582 593 if ($isMultiGuest && ($isDisplaySpots || $isMultiBooking)) {
@@ -647,9 +658,9 @@
647 658 }
648 659 }
649 660
650 661 $convertedSpots = array_map(function ($spots) {
651 - return array_values($spots);
662 + return array_values(ksort($spots) ? $spots : $spots);
652 663 }, $convertedSpots);
653 664
654 665 return $convertedSpots;
655 666 }
@@ -707,9 +718,9 @@
707 718
708 719 return $rangeArray;
709 720 }
710 721
711 - private function maybeBookingFrequencyLimitRanges($ranges, $bookedSlots)
722 + private function maybeBookingFrequencyLimitRanges($ranges)
712 723 {
713 724 if (!$ranges) {
714 725 return $ranges;
715 726 }
@@ -764,39 +775,12 @@
764 775 }
765 776 }
766 777 }
767 778 }
768 -
769 - // Per Day Booking Frequency Limit Hanlder
770 - if (!empty($keyedFrequenceyLimits['per_day'])) {
771 - $perDayLimit = $keyedFrequenceyLimits['per_day'];
772 - foreach ($ranges as $rangeIndex => $rangeDate) {
773 - if (!isset($bookedSlots[$rangeDate])) {
774 - continue;
775 - }
776 -
777 - $dayBooked = array_filter($bookedSlots[$rangeDate], function ($slot) {
778 - return Arr::get($slot, 'event_id') == $this->calendarSlot->id;
779 - });
780 -
781 - if (!$dayBooked) {
782 - continue;
783 - }
784 -
785 - if (count($dayBooked) >= $perDayLimit) {
786 - unset($ranges[$rangeIndex]);
787 - }
788 -
789 - if (!$ranges) {
790 - return [];
791 - }
792 - }
793 - }
794 -
795 779 return $ranges;
796 780 }
797 781
798 - private function maybeBookingDurationLimitRanges($ranges, $bookedSlots, $duration)
782 + private function maybeBookingDurationLimitRanges($ranges, $duration)
799 783 {
800 784 if (!$ranges) {
801 785 return $ranges;
802 786 }
@@ -843,35 +827,153 @@
843 827 }
844 828 }
845 829 }
846 830 }
831 + return $ranges;
832 + }
847 833
848 - // Per Day Booking Frequency Limit Hanlder
849 - if (!empty($keyedLimits['per_day'])) {
850 - $perDayLimit = $keyedLimits['per_day'];
851 - foreach ($ranges as $rangeIndex => $rangeDate) {
852 - if (!isset($bookedSlots[$rangeDate])) {
853 - continue;
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);
854 889 }
890 + return $carry;
891 + }, 0);
855 892
856 - $dayDurarion = array_reduce($bookedSlots[$rangeDate], function ($carry, $slot) {
857 - if (Arr::get($slot, 'event_id') == $this->calendarSlot->id) {
858 - $carry += (int)((strtotime($slot['end']) - strtotime($slot['start'])) / 60);
859 - }
860 - return $carry;
861 - }, 0);
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 + }
862 904
863 - if (!$dayDurarion) {
864 - continue;
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 + }));
865 944 }
945 + if (!$isMultiSlot || !count($slots)) {
946 + unset($rangesSlots[$rangeDate]);
947 + }
948 + }
949 + }
866 950
867 - if ($dayDurarion + $duration > $perDayLimit) {
868 - unset($ranges[$rangeIndex]);
869 - }
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;
870 972 }
871 973 }
872 974
873 - return $ranges;
975 + return $convertedSlots;
874 976 }
875 977
876 978 public function getFilledWeeks($from, $to, $weekStart = '')
877 979 {
@@ -938,9 +1040,9 @@
938 1040 }
939 1041
940 1042 protected function getMaxBookingTimestamp($fromDate, $toDate, $timeZone)
941 1043 {
942 - $maxBookingTime = $this->calendarSlot->getMaxBookableDateTime($fromDate, $timeZone, 'Y-m-d H:i:s');
1044 + $maxBookingTime = $this->calendarSlot->getMaxBookableDateTime($toDate, $timeZone, 'Y-m-d H:i:s');
943 1045
944 1046 return strtotime($maxBookingTime);
945 1047 }
946 1048