| @@ -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 | |