| @@ -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 | |
| @@ -234,9 +235,9 @@ | ||
| 234 | 235 | $endDate = gmdate('Y-m-t 23:59:59', strtotime($startDate)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date |
| 235 | 236 | } |
| 236 | 237 | |
| 237 | 238 | $currentDate = strtotime($startDate); |
| 238 | - $endDate = strtotime($endDate); | |
| 239 | + $endDate = strtotime($endDate) + 1; // add 1s in case end is 23:59:59 | |
| 239 | 240 | $oneDay = 24 * 60 * 60; |
| 240 | 241 | |
| 241 | 242 | $dateArray = []; |
| 242 | 243 | |
| @@ -268,16 +269,20 @@ | ||
| 268 | 269 | |
| 269 | 270 | $hostIds = $this->calendarSlot->getHostIds($this->hostId); |
| 270 | 271 | $status = ['pending', 'reserved', 'approved', 'scheduled', 'completed']; |
| 271 | 272 | |
| 273 | + // Single indexed start_time range: widen the lower bound by max booking duration to catch overlaps. | |
| 274 | + $maxDurationMinutes = (int) apply_filters('fluent_booking/max_booking_duration_minutes', DAY_IN_SECONDS / MINUTE_IN_SECONDS, $this->calendarSlot); | |
| 275 | + | |
| 276 | + $rangeLowerBound = gmdate('Y-m-d H:i:s', strtotime($dateRange[0]) - $maxDurationMinutes * MINUTE_IN_SECONDS); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date | |
| 277 | + | |
| 272 | 278 | $bookings = Booking::with(['calendar_event']) |
| 273 | 279 | ->whereHas('hosts', function ($query) use ($hostIds) { |
| 274 | 280 | $query->whereIn('user_id', $hostIds); |
| 275 | 281 | }) |
| 276 | - ->where(function ($query) use ($dateRange) { | |
| 277 | - $query->whereBetween('start_time', $dateRange) | |
| 278 | - ->orWhereBetween('end_time', $dateRange); | |
| 279 | - }) | |
| 282 | + ->where('start_time', '>=', $rangeLowerBound) | |
| 283 | + ->where('start_time', '<=', $dateRange[1]) | |
| 284 | + ->where('end_time', '>=', $dateRange[0]) | |
| 280 | 285 | ->orderBy('start_time', 'ASC') |
| 281 | 286 | ->whereIn('status', $status) |
| 282 | 287 | ->get() |
| 283 | 288 | ->groupBy('group_id'); |
| @@ -393,9 +398,9 @@ | ||
| 393 | 398 | |
| 394 | 399 | $interval = $this->calendarSlot->getSlotInterval($duration) * 60; |
| 395 | 400 | |
| 396 | 401 | $weeklySlots = $this->calendarSlot->getWeeklySlots($hostId); |
| 397 | - | |
| 402 | + | |
| 398 | 403 | $items = $this->getEnabledSlots($weeklySlots); |
| 399 | 404 | |
| 400 | 405 | // create range of each day slots from $items array above with $period minutes interval |
| 401 | 406 | $formattedSlots = []; |
| @@ -444,12 +449,12 @@ | ||
| 444 | 449 | if (isset($days[$nextDayIndex])) { |
| 445 | 450 | $nextDay = $items[$days[$nextDayIndex]]; |
| 446 | 451 | |
| 447 | 452 | if ($nextDay && $nextDay[0]['start'] == '00:00') { |
| 448 | - $nextDayEnd = strtotime($nextDay[0]['end']); | |
| 453 | + $nextDayEndTime = strtotime($nextDay[0]['end']) - strtotime($nextDay[0]['start']); | |
| 449 | 454 | $reserveTime = $end - $start; |
| 450 | 455 | |
| 451 | - while ($period - $reserveTime <= $nextDayEnd) { | |
| 456 | + while ($period - $reserveTime <= $nextDayEndTime) { | |
| 452 | 457 | $startTime = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date |
| 453 | 458 | $nextDayStart = gmdate('H:i', $interval - $reserveTime); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date |
| 454 | 459 | $daySlots[] = $startTime; |
| 455 | 460 | |
| @@ -523,12 +528,12 @@ | ||
| 523 | 528 | if (isset($overrideSlots[$nextDayIndex])) { |
| 524 | 529 | $nextDay = $overrideSlots[$nextDayIndex]; |
| 525 | 530 | |
| 526 | 531 | if ($nextDay && $nextDay[0]['start'] == '00:00') { |
| 527 | - $nextDayEnd = strtotime($nextDay[0]['end']); | |
| 532 | + $nextDayEndTime = strtotime($nextDay[0]['end']) - strtotime($nextDay[0]['start']); | |
| 528 | 533 | $reserveTime = $end - $start; |
| 529 | 534 | |
| 530 | - while ($period - $reserveTime <= $nextDayEnd) { | |
| 535 | + while ($period - $reserveTime <= $nextDayEndTime) { | |
| 531 | 536 | $startTime = gmdate('H:i', $start); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date |
| 532 | 537 | $nextDayStart = gmdate('H:i', $interval - $reserveTime); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date |
| 533 | 538 | $formattedSlots[] = $startTime; |
| 534 | 539 | |
| @@ -566,17 +571,16 @@ | ||
| 566 | 571 | { |
| 567 | 572 | $this->hostId = $hostId; |
| 568 | 573 | |
| 569 | 574 | $event = $this->calendarSlot; |
| 570 | - $calendar = $this->calendar; | |
| 571 | 575 | $duration = $event->getDuration($duration); |
| 572 | 576 | |
| 573 | - $startDate = $this->adjustStartDate($startDate, $timeZone); | |
| 577 | + $adjustedDate = $this->adjustStartDate($startDate, $timeZone); | |
| 574 | 578 | |
| 575 | 579 | $isDisplaySpots = $event->is_display_spots; |
| 576 | 580 | $isMultiGuest = $event->isMultiGuestEvent(); |
| 577 | 581 | $isMultiBooking = $event->isAdditionalGuestEnabled(); |
| 578 | - $endDate = $event->getMaxBookableDateTime($startDate, $timeZone); | |
| 582 | + $endDate = $event->getMaxBookableDateTime($adjustedDate, $timeZone); | |
| 579 | 583 | $startDate = $event->getMinBookableDateTime($startDate, $timeZone); |
| 580 | 584 | |
| 581 | 585 | $maxBooking = false; |
| 582 | 586 | if ($isMultiGuest && ($isDisplaySpots || $isMultiBooking)) { |
| @@ -647,9 +651,9 @@ | ||
| 647 | 651 | } |
| 648 | 652 | } |
| 649 | 653 | |
| 650 | 654 | $convertedSpots = array_map(function ($spots) { |
| 651 | - return array_values($spots); | |
| 655 | + return array_values(ksort($spots) ? $spots : $spots); | |
| 652 | 656 | }, $convertedSpots); |
| 653 | 657 | |
| 654 | 658 | return $convertedSpots; |
| 655 | 659 | } |
| @@ -707,9 +711,9 @@ | ||
| 707 | 711 | |
| 708 | 712 | return $rangeArray; |
| 709 | 713 | } |
| 710 | 714 | |
| 711 | - private function maybeBookingFrequencyLimitRanges($ranges, $bookedSlots) | |
| 715 | + private function maybeBookingFrequencyLimitRanges($ranges) | |
| 712 | 716 | { |
| 713 | 717 | if (!$ranges) { |
| 714 | 718 | return $ranges; |
| 715 | 719 | } |
| @@ -764,39 +768,12 @@ | ||
| 764 | 768 | } |
| 765 | 769 | } |
| 766 | 770 | } |
| 767 | 771 | } |
| 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 | 772 | return $ranges; |
| 796 | 773 | } |
| 797 | 774 | |
| 798 | - private function maybeBookingDurationLimitRanges($ranges, $bookedSlots, $duration) | |
| 775 | + private function maybeBookingDurationLimitRanges($ranges, $duration) | |
| 799 | 776 | { |
| 800 | 777 | if (!$ranges) { |
| 801 | 778 | return $ranges; |
| 802 | 779 | } |
| @@ -843,35 +820,153 @@ | ||
| 843 | 820 | } |
| 844 | 821 | } |
| 845 | 822 | } |
| 846 | 823 | } |
| 824 | + return $ranges; | |
| 825 | + } | |
| 847 | 826 | |
| 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; | |
| 827 | + protected function maybeBookingPerDayLimitSlots($rangesSlots, $bookedSlots, $duration) | |
| 828 | + { | |
| 829 | + $isDurationEnabled = !!Arr::get($this->calendarSlot->settings, 'booking_duration.enabled'); | |
| 830 | + | |
| 831 | + $isFrequencyEnabled = !!Arr::get($this->calendarSlot->settings, 'booking_frequency.enabled'); | |
| 832 | + | |
| 833 | + if (!$isDurationEnabled && !$isFrequencyEnabled) { | |
| 834 | + return $rangesSlots; | |
| 835 | + } | |
| 836 | + | |
| 837 | + $hostTimeZone = $this->calendarSlot->getScheduleTimezone($this->hostId); | |
| 838 | + | |
| 839 | + $convertedRangesSlots = $this->convertSlotsByTimezone($rangesSlots, 'UTC', $hostTimeZone); | |
| 840 | + | |
| 841 | + $convertedBookedSlots = $this->convertSlotsByTimezone($bookedSlots, 'UTC', $hostTimeZone); | |
| 842 | + | |
| 843 | + $convertedRangesSlots = $this->maybeBookingDurationDayLimit($convertedRangesSlots, $convertedBookedSlots, $duration, $isDurationEnabled); | |
| 844 | + | |
| 845 | + $convertedRangesSlots = $this->maybeBookingFrequencyDayLimit($convertedRangesSlots, $convertedBookedSlots, $isFrequencyEnabled); | |
| 846 | + | |
| 847 | + $rangesSlots = $this->convertSlotsByTimezone($convertedRangesSlots, $hostTimeZone, 'UTC'); | |
| 848 | + | |
| 849 | + return $rangesSlots; | |
| 850 | + } | |
| 851 | + | |
| 852 | + protected function maybeBookingDurationDayLimit($rangesSlots, $bookedSlots, $duration, $isEnabled) | |
| 853 | + { | |
| 854 | + if (!$isEnabled) { | |
| 855 | + return $rangesSlots; | |
| 856 | + } | |
| 857 | + | |
| 858 | + $limits = Arr::get($this->calendarSlot->settings, 'booking_duration.limits', []); | |
| 859 | + | |
| 860 | + $perDayLimit = null; | |
| 861 | + foreach ($limits as $limit) { | |
| 862 | + if (Arr::get($limit, 'unit') == 'per_day' && Arr::get($limit, 'value')) { | |
| 863 | + $perDayLimit = (int)Arr::get($limit, 'value'); | |
| 864 | + break; | |
| 865 | + } | |
| 866 | + } | |
| 867 | + | |
| 868 | + if (!$perDayLimit) { | |
| 869 | + return $rangesSlots; | |
| 870 | + } | |
| 871 | + | |
| 872 | + $isMultiSlot = $this->calendarSlot->isMultiGuestEvent(); | |
| 873 | + | |
| 874 | + foreach ($rangesSlots as $rangeDate => &$slots) { | |
| 875 | + if (!isset($bookedSlots[$rangeDate])) { | |
| 876 | + continue; | |
| 877 | + } | |
| 878 | + | |
| 879 | + $dayDuration = array_reduce($bookedSlots[$rangeDate], function ($carry, $slot) { | |
| 880 | + if (Arr::get($slot, 'event_id') == $this->calendarSlot->id) { | |
| 881 | + $carry += (int)((strtotime($slot['end']) - strtotime($slot['start'])) / 60); | |
| 854 | 882 | } |
| 883 | + return $carry; | |
| 884 | + }, 0); | |
| 855 | 885 | |
| 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); | |
| 886 | + if ($dayDuration + $duration > $perDayLimit) { | |
| 887 | + if ($isMultiSlot) { | |
| 888 | + $slots = array_values(array_filter($slots, function ($slot) { | |
| 889 | + return !empty(Arr::get($slot, 'remaining')); | |
| 890 | + })); | |
| 891 | + } | |
| 892 | + if (!$isMultiSlot || !count($slots)) { | |
| 893 | + unset($rangesSlots[$rangeDate]); | |
| 894 | + } | |
| 895 | + } | |
| 896 | + } | |
| 862 | 897 | |
| 863 | - if (!$dayDurarion) { | |
| 864 | - continue; | |
| 898 | + return $rangesSlots; | |
| 899 | + } | |
| 900 | + | |
| 901 | + protected function maybeBookingFrequencyDayLimit($rangesSlots, $bookedSlots, $isEnabled) | |
| 902 | + { | |
| 903 | + if (!$isEnabled) { | |
| 904 | + return $rangesSlots; | |
| 905 | + } | |
| 906 | + | |
| 907 | + $limits = Arr::get($this->calendarSlot->settings, 'booking_frequency.limits', []); | |
| 908 | + | |
| 909 | + $perDayLimit = null; | |
| 910 | + foreach ($limits as $limit) { | |
| 911 | + if (Arr::get($limit, 'unit') == 'per_day' && Arr::get($limit, 'value')) { | |
| 912 | + $perDayLimit = (int)Arr::get($limit, 'value'); | |
| 913 | + break; | |
| 914 | + } | |
| 915 | + } | |
| 916 | + | |
| 917 | + if (!$perDayLimit) { | |
| 918 | + return $rangesSlots; | |
| 919 | + } | |
| 920 | + | |
| 921 | + $isMultiSlot = $this->calendarSlot->isMultiGuestEvent(); | |
| 922 | + | |
| 923 | + foreach ($rangesSlots as $rangeDate => &$slots) { | |
| 924 | + if (!isset($bookedSlots[$rangeDate])) { | |
| 925 | + continue; | |
| 926 | + } | |
| 927 | + | |
| 928 | + $dayBooked = array_filter($bookedSlots[$rangeDate], function ($slot) { | |
| 929 | + return Arr::get($slot, 'event_id') == $this->calendarSlot->id; | |
| 930 | + }); | |
| 931 | + | |
| 932 | + if (count($dayBooked) >= $perDayLimit) { | |
| 933 | + if ($isMultiSlot) { | |
| 934 | + $slots = array_values(array_filter($slots, function ($slot) { | |
| 935 | + return !empty(Arr::get($slot, 'remaining')); | |
| 936 | + })); | |
| 865 | 937 | } |
| 938 | + if (!$isMultiSlot || !count($slots)) { | |
| 939 | + unset($rangesSlots[$rangeDate]); | |
| 940 | + } | |
| 941 | + } | |
| 942 | + } | |
| 866 | 943 | |
| 867 | - if ($dayDurarion + $duration > $perDayLimit) { | |
| 868 | - unset($ranges[$rangeIndex]); | |
| 869 | - } | |
| 944 | + return $rangesSlots; | |
| 945 | + } | |
| 946 | + | |
| 947 | + protected function convertSlotsByTimezone($slots, $fromTimeZone, $toTimeZone) | |
| 948 | + { | |
| 949 | + if ($fromTimeZone == $toTimeZone) { | |
| 950 | + return $slots; | |
| 951 | + } | |
| 952 | + | |
| 953 | + $convertedSlots = []; | |
| 954 | + | |
| 955 | + foreach ($slots as $spots) { | |
| 956 | + foreach ($spots as $spot) { | |
| 957 | + $spot['start'] = DateTimeHelper::convertToTimeZone($spot['start'], $fromTimeZone, $toTimeZone); | |
| 958 | + $spot['end'] = DateTimeHelper::convertToTimeZone($spot['end'], $fromTimeZone, $toTimeZone); | |
| 959 | + | |
| 960 | + $spotDate = gmdate('Y-m-d', strtotime($spot['start'])); | |
| 961 | + | |
| 962 | + $convertedSlots[$spotDate] = $convertedSlots[$spotDate] ?? []; | |
| 963 | + | |
| 964 | + $convertedSlots[$spotDate][] = $spot; | |
| 870 | 965 | } |
| 871 | 966 | } |
| 872 | 967 | |
| 873 | - return $ranges; | |
| 968 | + return $convertedSlots; | |
| 874 | 969 | } |
| 875 | 970 | |
| 876 | 971 | public function getFilledWeeks($from, $to, $weekStart = '') |
| 877 | 972 | { |
| @@ -938,9 +1033,9 @@ | ||
| 938 | 1033 | } |
| 939 | 1034 | |
| 940 | 1035 | protected function getMaxBookingTimestamp($fromDate, $toDate, $timeZone) |
| 941 | 1036 | { |
| 942 | - $maxBookingTime = $this->calendarSlot->getMaxBookableDateTime($fromDate, $timeZone, 'Y-m-d H:i:s'); | |
| 1037 | + $maxBookingTime = $this->calendarSlot->getMaxBookableDateTime($toDate, $timeZone, 'Y-m-d H:i:s'); | |
| 943 | 1038 | |
| 944 | 1039 | return strtotime($maxBookingTime); |
| 945 | 1040 | } |
| 946 | 1041 | |