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 +209 -76 1.5.20 → 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,13 +176,18 @@
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
@@ -172,9 +192,9 @@
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
@@ -182,20 +202,28 @@
182 202 }
183 203
184 204 protected function isSlotExists($availableSlots, $fromTimeStamp, $toTimeStamp)
185 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 +
186 213 $left = 0;
187 214 $right = count($availableSlots) - 1;
188 215
189 216 while ($left <= $right) {
190 217 $mid = $left + (($right - $left) >> 1);
218 + $slot = $availableSlots[$mid];
191 219
192 - $midStartTime = strtotime($availableSlots[$mid]['start']);
193 - $midEndTime = strtotime($availableSlots[$mid]['end']);
220 + $slotStartTime = strtotime($slot['start']);
221 + $slotEndTime = strtotime($slot['end']);
194 222
195 - if ($fromTimeStamp == $midStartTime && $toTimeStamp == $midEndTime) {
196 - return true;
197 - } elseif ($fromTimeStamp > $midStartTime) {
223 + if ($fromTimeStamp == $slotStartTime && $toTimeStamp == $slotEndTime) {
224 + return $slot;
225 + } elseif ($fromTimeStamp > $slotStartTime) {
198 226 $left = $mid + 1;
199 227 } else {
200 228 $right = $mid - 1;
201 229 }
@@ -214,9 +242,9 @@
214 242 $endDate = gmdate('Y-m-t 23:59:59', strtotime($startDate)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
215 243 }
216 244
217 245 $currentDate = strtotime($startDate);
218 - $endDate = strtotime($endDate);
246 + $endDate = strtotime($endDate) + 1; // add 1s in case end is 23:59:59
219 247 $oneDay = 24 * 60 * 60;
220 248
221 249 $dateArray = [];
222 250
@@ -248,13 +276,20 @@
248 276
249 277 $hostIds = $this->calendarSlot->getHostIds($this->hostId);
250 278 $status = ['pending', 'reserved', 'approved', 'scheduled', 'completed'];
251 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 +
252 285 $bookings = Booking::with(['calendar_event'])
253 286 ->whereHas('hosts', function ($query) use ($hostIds) {
254 287 $query->whereIn('user_id', $hostIds);
255 288 })
256 - ->whereBetween('start_time', $dateRange)
289 + ->where('start_time', '>=', $rangeLowerBound)
290 + ->where('start_time', '<=', $dateRange[1])
291 + ->where('end_time', '>=', $dateRange[0])
257 292 ->orderBy('start_time', 'ASC')
258 293 ->whereIn('status', $status)
259 294 ->get()
260 295 ->groupBy('group_id');
@@ -370,9 +405,9 @@
370 405
371 406 $interval = $this->calendarSlot->getSlotInterval($duration) * 60;
372 407
373 408 $weeklySlots = $this->calendarSlot->getWeeklySlots($hostId);
374 -
409 +
375 410 $items = $this->getEnabledSlots($weeklySlots);
376 411
377 412 // create range of each day slots from $items array above with $period minutes interval
378 413 $formattedSlots = [];
@@ -421,12 +456,12 @@
421 456 if (isset($days[$nextDayIndex])) {
422 457 $nextDay = $items[$days[$nextDayIndex]];
423 458
424 459 if ($nextDay && $nextDay[0]['start'] == '00:00') {
425 - $nextDayEnd = strtotime($nextDay[0]['end']);
460 + $nextDayEndTime = strtotime($nextDay[0]['end']) - strtotime($nextDay[0]['start']);
426 461 $reserveTime = $end - $start;
427 462
428 - while ($period - $reserveTime <= $nextDayEnd) {
463 + while ($period - $reserveTime <= $nextDayEndTime) {
429 464 $startTime = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
430 465 $nextDayStart = gmdate('H:i', $interval - $reserveTime); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
431 466 $daySlots[] = $startTime;
432 467
@@ -500,12 +535,12 @@
500 535 if (isset($overrideSlots[$nextDayIndex])) {
501 536 $nextDay = $overrideSlots[$nextDayIndex];
502 537
503 538 if ($nextDay && $nextDay[0]['start'] == '00:00') {
504 - $nextDayEnd = strtotime($nextDay[0]['end']);
539 + $nextDayEndTime = strtotime($nextDay[0]['end']) - strtotime($nextDay[0]['start']);
505 540 $reserveTime = $end - $start;
506 541
507 - while ($period - $reserveTime <= $nextDayEnd) {
542 + while ($period - $reserveTime <= $nextDayEndTime) {
508 543 $startTime = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
509 544 $nextDayStart = gmdate('H:i', $interval - $reserveTime); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
510 545 $formattedSlots[] = $startTime;
511 546
@@ -543,20 +578,20 @@
543 578 {
544 579 $this->hostId = $hostId;
545 580
546 581 $event = $this->calendarSlot;
547 - $calendar = $this->calendar;
548 582 $duration = $event->getDuration($duration);
549 583
550 - $startDate = $this->adjustStartDate($startDate, $timeZone);
584 + $adjustedDate = $this->adjustStartDate($startDate, $timeZone);
551 585
552 586 $isDisplaySpots = $event->is_display_spots;
553 587 $isMultiGuest = $event->isMultiGuestEvent();
554 - $endDate = $event->getMaxBookableDateTime($startDate, $timeZone);
588 + $isMultiBooking = $event->isAdditionalGuestEnabled();
589 + $endDate = $event->getMaxBookableDateTime($adjustedDate, $timeZone);
555 590 $startDate = $event->getMinBookableDateTime($startDate, $timeZone);
556 591
557 592 $maxBooking = false;
558 - if ($isDisplaySpots && $isMultiGuest) {
593 + if ($isMultiGuest && ($isDisplaySpots || $isMultiBooking)) {
559 594 $maxBooking = $event->getMaxBookingPerSlot();
560 595 }
561 596
562 597 if (strtotime($startDate) > strtotime($endDate)) {
@@ -623,9 +658,9 @@
623 658 }
624 659 }
625 660
626 661 $convertedSpots = array_map(function ($spots) {
627 - return array_values($spots);
662 + return array_values(ksort($spots) ? $spots : $spots);
628 663 }, $convertedSpots);
629 664
630 665 return $convertedSpots;
631 666 }
@@ -683,9 +718,9 @@
683 718
684 719 return $rangeArray;
685 720 }
686 721
687 - private function maybeBookingFrequencyLimitRanges($ranges, $bookedSlots)
722 + private function maybeBookingFrequencyLimitRanges($ranges)
688 723 {
689 724 if (!$ranges) {
690 725 return $ranges;
691 726 }
@@ -740,39 +775,12 @@
740 775 }
741 776 }
742 777 }
743 778 }
744 -
745 - // Per Day Booking Frequency Limit Hanlder
746 - if (!empty($keyedFrequenceyLimits['per_day'])) {
747 - $perDayLimit = $keyedFrequenceyLimits['per_day'];
748 - foreach ($ranges as $rangeIndex => $rangeDate) {
749 - if (!isset($bookedSlots[$rangeDate])) {
750 - continue;
751 - }
752 -
753 - $dayBooked = array_filter($bookedSlots[$rangeDate], function ($slot) {
754 - return Arr::get($slot, 'event_id') == $this->calendarSlot->id;
755 - });
756 -
757 - if (!$dayBooked) {
758 - continue;
759 - }
760 -
761 - if (count($dayBooked) >= $perDayLimit) {
762 - unset($ranges[$rangeIndex]);
763 - }
764 -
765 - if (!$ranges) {
766 - return [];
767 - }
768 - }
769 - }
770 -
771 779 return $ranges;
772 780 }
773 781
774 - private function maybeBookingDurationLimitRanges($ranges, $bookedSlots, $duration)
782 + private function maybeBookingDurationLimitRanges($ranges, $duration)
775 783 {
776 784 if (!$ranges) {
777 785 return $ranges;
778 786 }
@@ -819,35 +827,153 @@
819 827 }
820 828 }
821 829 }
822 830 }
831 + return $ranges;
832 + }
823 833
824 - // Per Day Booking Frequency Limit Hanlder
825 - if (!empty($keyedLimits['per_day'])) {
826 - $perDayLimit = $keyedLimits['per_day'];
827 - foreach ($ranges as $rangeIndex => $rangeDate) {
828 - if (!isset($bookedSlots[$rangeDate])) {
829 - 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);
830 889 }
890 + return $carry;
891 + }, 0);
831 892
832 - $dayDurarion = array_reduce($bookedSlots[$rangeDate], function ($carry, $slot) {
833 - if (Arr::get($slot, 'event_id') == $this->calendarSlot->id) {
834 - $carry += (int)((strtotime($slot['end']) - strtotime($slot['start'])) / 60);
835 - }
836 - return $carry;
837 - }, 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 + }
838 904
839 - if (!$dayDurarion) {
840 - 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 + }));
841 944 }
945 + if (!$isMultiSlot || !count($slots)) {
946 + unset($rangesSlots[$rangeDate]);
947 + }
948 + }
949 + }
842 950
843 - if ($dayDurarion + $duration > $perDayLimit) {
844 - unset($ranges[$rangeIndex]);
845 - }
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;
846 972 }
847 973 }
848 974
849 - return $ranges;
975 + return $convertedSlots;
850 976 }
851 977
852 978 public function getFilledWeeks($from, $to, $weekStart = '')
853 979 {
@@ -910,8 +1036,15 @@
910 1036 ->where('event_id', $this->calendarSlot->id)
911 1037 ->whereBetween('start_time', [$start, $end])
912 1038 ->whereIn('status', ['scheduled', 'completed'])
913 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);
914 1047 }
915 1048
916 1049 protected function getTimezoneInfo($hostId = null)
917 1050 {