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 +215 -77 1.5.1 → 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,19 +33,21 @@
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 - $maxBookingTime = strtotime($this->calendarSlot->getMaxBookableDateTime($fromDate, $timeZone, 'Y-m-d H:i:s'));
42 + $maxBookingTime = $this->getMaxBookingTimestamp($fromDate, $toDate, $timeZone);
44 43
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)
@@ -77,10 +78,8 @@
77 78 if (!$availableSlots) {
78 79 continue;
79 80 }
80 81
81 - $currentBookedSlots = $bookedSlots[$date] ?? [];
82 -
83 82 $isToday = $date === $todayDate;
84 83
85 84 $isLastDay = $date === $lastDate;
86 85
@@ -85,8 +84,10 @@
85 84 $isLastDay = $date === $lastDate;
86 85
87 86 $validSlots = [];
88 87
88 + $validDstDateSlots = [];
89 +
89 90 foreach ($availableSlots as $start) {
90 91 $end = gmdate('H:i', strtotime($start) + $period); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
91 92 $endDate = $start < $end ? $date : gmdate('Y-m-d', strtotime($date) + 86400); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
92 93
@@ -96,20 +97,28 @@
96 97 ];
97 98
98 99 $slot = $this->maybeDayLightSavingSlot($slot, $dstTime, $scheduleTimezone);
99 100
101 + $slotDate = gmdate('Y-m-d', strtotime($slot['start'])); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
102 +
103 + $currentBookedSlots = $bookedSlots[$slotDate] ?? [];
104 +
100 105 if ($isToday && strtotime($slot['start']) < $cutOutTime) {
101 106 continue;
102 107 }
103 108
104 109 if ($isLastDay && strtotime($slot['end']) > $maxBookingTime) {
105 - continue;
110 + break;
106 111 }
107 112
108 113 $isSlotAvailable = $this->isSlotAvailable($slot, $currentBookedSlots, $bufferTime, $hostId);
109 114
110 115 if ($isSlotAvailable) {
111 - $validSlots[] = $slot;
116 + if ($slotDate != $date) {
117 + $validDstDateSlots[] = $slot;
118 + } else {
119 + $validSlots[] = $slot;
120 + }
112 121 }
113 122 }
114 123
115 124 if ($validSlots) {
@@ -115,8 +124,14 @@
115 124 if ($validSlots) {
116 125 $currentSlots = $rangedSlots[$date] ?? [];
117 126 $rangedSlots[$date] = $this->mergeAndSortSlots($currentSlots, $validSlots);
118 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 + }
119 134 }
120 135
121 136 return $rangedSlots;
122 137 }
@@ -161,36 +176,54 @@
161 176
162 177 $fromTimeStamp = strtotime($fromTime);
163 178 $toTimeStamp = strtotime($toTime);
164 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.
165 187 $fromTime = gmdate('Y-m-d 00:00:00', $fromTimeStamp); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
166 - $toTime = gmdate('Y-m-d 23:59:59', $toTimeStamp); // 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
167 189
168 - $duration = $this->calendarSlot->getDuration($duration);
169 -
170 190 $slots = $this->getDates($fromTime, $toTime, $duration, true);
171 191
172 192 $fromDate = gmdate('Y-m-d', $fromTimeStamp); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
173 193 $toDate = gmdate('Y-m-d', $toTimeStamp); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
174 -
194 +
175 195 $availableSlots = $slots[$fromDate] ?? [];
176 -
196 +
177 197 if ($fromDate != $toDate) {
178 198 $availableSlots = array_merge($availableSlots, $slots[$toDate] ?? []);
179 199 }
180 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 +
181 213 $left = 0;
182 214 $right = count($availableSlots) - 1;
183 215
184 216 while ($left <= $right) {
185 217 $mid = $left + (($right - $left) >> 1);
218 + $slot = $availableSlots[$mid];
186 219
187 - $midStartTime = strtotime($availableSlots[$mid]['start']);
188 - $midEndTime = strtotime($availableSlots[$mid]['end']);
220 + $slotStartTime = strtotime($slot['start']);
221 + $slotEndTime = strtotime($slot['end']);
189 222
190 - if ($fromTimeStamp == $midStartTime && $toTimeStamp == $midEndTime) {
191 - return true;
192 - } elseif ($fromTimeStamp > $midStartTime) {
223 + if ($fromTimeStamp == $slotStartTime && $toTimeStamp == $slotEndTime) {
224 + return $slot;
225 + } elseif ($fromTimeStamp > $slotStartTime) {
193 226 $left = $mid + 1;
194 227 } else {
195 228 $right = $mid - 1;
196 229 }
@@ -209,9 +242,9 @@
209 242 $endDate = gmdate('Y-m-t 23:59:59', strtotime($startDate)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
210 243 }
211 244
212 245 $currentDate = strtotime($startDate);
213 - $endDate = strtotime($endDate);
246 + $endDate = strtotime($endDate) + 1; // add 1s in case end is 23:59:59
214 247 $oneDay = 24 * 60 * 60;
215 248
216 249 $dateArray = [];
217 250
@@ -243,13 +276,20 @@
243 276
244 277 $hostIds = $this->calendarSlot->getHostIds($this->hostId);
245 278 $status = ['pending', 'reserved', 'approved', 'scheduled', 'completed'];
246 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 +
247 285 $bookings = Booking::with(['calendar_event'])
248 286 ->whereHas('hosts', function ($query) use ($hostIds) {
249 287 $query->whereIn('user_id', $hostIds);
250 288 })
251 - ->whereBetween('start_time', $dateRange)
289 + ->where('start_time', '>=', $rangeLowerBound)
290 + ->where('start_time', '<=', $dateRange[1])
291 + ->where('end_time', '>=', $dateRange[0])
252 292 ->orderBy('start_time', 'ASC')
253 293 ->whereIn('status', $status)
254 294 ->get()
255 295 ->groupBy('group_id');
@@ -365,9 +405,9 @@
365 405
366 406 $interval = $this->calendarSlot->getSlotInterval($duration) * 60;
367 407
368 408 $weeklySlots = $this->calendarSlot->getWeeklySlots($hostId);
369 -
409 +
370 410 $items = $this->getEnabledSlots($weeklySlots);
371 411
372 412 // create range of each day slots from $items array above with $period minutes interval
373 413 $formattedSlots = [];
@@ -416,12 +456,12 @@
416 456 if (isset($days[$nextDayIndex])) {
417 457 $nextDay = $items[$days[$nextDayIndex]];
418 458
419 459 if ($nextDay && $nextDay[0]['start'] == '00:00') {
420 - $nextDayEnd = strtotime($nextDay[0]['end']);
460 + $nextDayEndTime = strtotime($nextDay[0]['end']) - strtotime($nextDay[0]['start']);
421 461 $reserveTime = $end - $start;
422 462
423 - while ($period - $reserveTime <= $nextDayEnd) {
463 + while ($period - $reserveTime <= $nextDayEndTime) {
424 464 $startTime = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
425 465 $nextDayStart = gmdate('H:i', $interval - $reserveTime); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
426 466 $daySlots[] = $startTime;
427 467
@@ -495,12 +535,12 @@
495 535 if (isset($overrideSlots[$nextDayIndex])) {
496 536 $nextDay = $overrideSlots[$nextDayIndex];
497 537
498 538 if ($nextDay && $nextDay[0]['start'] == '00:00') {
499 - $nextDayEnd = strtotime($nextDay[0]['end']);
539 + $nextDayEndTime = strtotime($nextDay[0]['end']) - strtotime($nextDay[0]['start']);
500 540 $reserveTime = $end - $start;
501 541
502 - while ($period - $reserveTime <= $nextDayEnd) {
542 + while ($period - $reserveTime <= $nextDayEndTime) {
503 543 $startTime = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
504 544 $nextDayStart = gmdate('H:i', $interval - $reserveTime); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
505 545 $formattedSlots[] = $startTime;
506 546
@@ -538,20 +578,20 @@
538 578 {
539 579 $this->hostId = $hostId;
540 580
541 581 $event = $this->calendarSlot;
542 - $calendar = $this->calendar;
543 582 $duration = $event->getDuration($duration);
544 583
545 - $startDate = $this->adjustStartDate($startDate, $timeZone);
584 + $adjustedDate = $this->adjustStartDate($startDate, $timeZone);
546 585
547 586 $isDisplaySpots = $event->is_display_spots;
548 587 $isMultiGuest = $event->isMultiGuestEvent();
549 - $endDate = $event->getMaxBookableDateTime($startDate, $timeZone);
588 + $isMultiBooking = $event->isAdditionalGuestEnabled();
589 + $endDate = $event->getMaxBookableDateTime($adjustedDate, $timeZone);
550 590 $startDate = $event->getMinBookableDateTime($startDate, $timeZone);
551 591
552 592 $maxBooking = false;
553 - if ($isDisplaySpots && $isMultiGuest) {
593 + if ($isMultiGuest && ($isDisplaySpots || $isMultiBooking)) {
554 594 $maxBooking = $event->getMaxBookingPerSlot();
555 595 }
556 596
557 597 if (strtotime($startDate) > strtotime($endDate)) {
@@ -618,9 +658,9 @@
618 658 }
619 659 }
620 660
621 661 $convertedSpots = array_map(function ($spots) {
622 - return array_values($spots);
662 + return array_values(ksort($spots) ? $spots : $spots);
623 663 }, $convertedSpots);
624 664
625 665 return $convertedSpots;
626 666 }
@@ -678,9 +718,9 @@
678 718
679 719 return $rangeArray;
680 720 }
681 721
682 - private function maybeBookingFrequencyLimitRanges($ranges, $bookedSlots)
722 + private function maybeBookingFrequencyLimitRanges($ranges)
683 723 {
684 724 if (!$ranges) {
685 725 return $ranges;
686 726 }
@@ -735,39 +775,12 @@
735 775 }
736 776 }
737 777 }
738 778 }
739 -
740 - // Per Day Booking Frequency Limit Hanlder
741 - if (!empty($keyedFrequenceyLimits['per_day'])) {
742 - $perDayLimit = $keyedFrequenceyLimits['per_day'];
743 - foreach ($ranges as $rangeIndex => $rangeDate) {
744 - if (!isset($bookedSlots[$rangeDate])) {
745 - continue;
746 - }
747 -
748 - $dayBooked = array_filter($bookedSlots[$rangeDate], function ($slot) {
749 - return Arr::get($slot, 'event_id') == $this->calendarSlot->id;
750 - });
751 -
752 - if (!$dayBooked) {
753 - continue;
754 - }
755 -
756 - if (count($dayBooked) >= $perDayLimit) {
757 - unset($ranges[$rangeIndex]);
758 - }
759 -
760 - if (!$ranges) {
761 - return [];
762 - }
763 - }
764 - }
765 -
766 779 return $ranges;
767 780 }
768 781
769 - private function maybeBookingDurationLimitRanges($ranges, $bookedSlots, $duration)
782 + private function maybeBookingDurationLimitRanges($ranges, $duration)
770 783 {
771 784 if (!$ranges) {
772 785 return $ranges;
773 786 }
@@ -814,35 +827,153 @@
814 827 }
815 828 }
816 829 }
817 830 }
831 + return $ranges;
832 + }
818 833
819 - // Per Day Booking Frequency Limit Hanlder
820 - if (!empty($keyedLimits['per_day'])) {
821 - $perDayLimit = $keyedLimits['per_day'];
822 - foreach ($ranges as $rangeIndex => $rangeDate) {
823 - if (!isset($bookedSlots[$rangeDate])) {
824 - 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);
825 889 }
890 + return $carry;
891 + }, 0);
826 892
827 - $dayDurarion = array_reduce($bookedSlots[$rangeDate], function ($carry, $slot) {
828 - if (Arr::get($slot, 'event_id') == $this->calendarSlot->id) {
829 - $carry += (int)((strtotime($slot['end']) - strtotime($slot['start'])) / 60);
830 - }
831 - return $carry;
832 - }, 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 + }
833 904
834 - if (!$dayDurarion) {
835 - 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 + }));
836 944 }
945 + if (!$isMultiSlot || !count($slots)) {
946 + unset($rangesSlots[$rangeDate]);
947 + }
948 + }
949 + }
837 950
838 - if ($dayDurarion + $duration > $perDayLimit) {
839 - unset($ranges[$rangeIndex]);
840 - }
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;
841 972 }
842 973 }
843 974
844 - return $ranges;
975 + return $convertedSlots;
845 976 }
846 977
847 978 public function getFilledWeeks($from, $to, $weekStart = '')
848 979 {
@@ -905,8 +1036,15 @@
905 1036 ->where('event_id', $this->calendarSlot->id)
906 1037 ->whereBetween('start_time', [$start, $end])
907 1038 ->whereIn('status', ['scheduled', 'completed'])
908 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);
909 1047 }
910 1048
911 1049 protected function getTimezoneInfo($hostId = null)
912 1050 {