PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.21
Booktics – Appointment Booking Calendar for Service Businesses v1.0.21
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
booktics / core / appointment / schedule / time-slot-manager.php

time-slot-manager.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.21, at core/appointment/schedule/time-slot-manager.php

600 lines 19.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Time_Slot_Manager class
4 *
5 * @package Booktics\Appointment\Schedule
6 */
7
8 namespace Booktics\Appointment\Schedule;
9
10 use Booktics\Models\Appointment_Model;
11 use Booktics\Models\Team_Member_Model;
12 use Booktics\Module\Google_Calendar\Service\Calendar;
13 use Booktics\Services\Schedule_Record;
14 use DateTime;
15 use Exception;
16
17 /**
18 * Class Time_Slot_Manager
19 */
20 class Time_Slot_Manager {
21 /**
22 * Store Service Schedule Object
23 *
24 * @var Service_Schedule
25 */
26 private $service_schedule;
27
28 /**
29 * Store Team member Schedule Object
30 *
31 * @var Team_Member_Schedule
32 */
33 private $team_member_schedule;
34
35 /**
36 * Store Default Schedule Object
37 *
38 * @var Default_Schedule
39 */
40 private $default_schedule;
41
42 /**
43 * Store service duration in minutes
44 *
45 * @var integer
46 */
47 private $service_duration;
48
49 /**
50 * Store slot length in minutes
51 *
52 * @var integer
53 */
54 private $slot_length;
55
56 /**
57 * Store before buffer time in minutes
58 *
59 * @var integer
60 */
61 private $before_buffer_time;
62
63 /**
64 * Store after buffer time in minutes
65 *
66 * @var integer
67 */
68 private $after_buffer_time;
69
70 /**
71 * Store time format (12 or 24 hour)
72 *
73 * @var string
74 */
75 private $slot_format = '12'; // Default to 12 hour format.
76
77 /**
78 * Store service id
79 */
80 private $service_id = null;
81
82 /**
83 * Number of attendees for time slot
84 */
85 private $total_attendees = 1;
86
87 /**
88 * Cached Google Calendar booked slots grouped by date (Y-m-d => array of slots).
89 * Populated once per range request to avoid repeated API calls.
90 *
91 * @var array|null
92 */
93 private $google_booked_slots_cache = null;
94
95 /**
96 * Constructor
97 *
98 * @param Service_Schedule $service Service schedule object.
99 * @param Team_Member_Schedule $team_member Team member schedule object.
100 * @param array $options Optional parameters as an associative array.
101 */
102 public function __construct( Service_Schedule $service, Team_Member_Schedule $team_member, $options = array() ) {
103 $defaults = array(
104 'default_schedule' => null,
105 'service_duration' => 30,
106 'slot_length' => 30,
107 'before_buffer_time' => 0,
108 'after_buffer_time' => 0,
109 'total_attendees' => 1,
110 );
111 $args = \wp_parse_args( $options, $defaults );
112
113 $this->service_schedule = $service;
114 $this->team_member_schedule = $team_member;
115 $this->default_schedule = $args['default_schedule'];
116 $this->service_duration = $args['service_duration'];
117 $this->slot_length = $args['slot_length'];
118 $this->before_buffer_time = $args['before_buffer_time'];
119 $this->after_buffer_time = $args['after_buffer_time'];
120 $this->service_id = $args['service_id'] ?? null;
121 $this->total_attendees = $args['total_attendees'];
122 }
123
124 /**
125 * Remove duplicates and sort time slots
126 *
127 * @param array $slots Array of time slots to deduplicate and sort.
128 * @return array Deduplicated and sorted array of time slots
129 */
130 private function deduplicate_and_sort_slots( array $slots ): array {
131 // Remove duplicates based on start and end times
132 $unique = array();
133 $seen = array();
134
135 foreach ( $slots as $slot ) {
136 $key = $slot->start . '-' . $slot->end;
137 if ( ! isset( $seen[ $key ] ) ) {
138 $seen[ $key ] = true;
139 $unique[] = $slot;
140 }
141 }
142
143 // Sort slots by start time
144 usort(
145 $unique,
146 function ( $a, $b ) {
147 $time_a = strtotime( $a->start );
148 $time_b = strtotime( $b->start );
149 return $time_a - $time_b;
150 }
151 );
152
153 return $unique;
154 }
155
156 /**
157 * Generate time slots for a specific date
158 *
159 * @param DateTime $date
160 *
161 * @return array
162 */
163 public function generate_slots( DateTime $date ): array {
164 $service_entries = $this->service_schedule->get_first_date( $date );
165 $team_member_entries = $this->team_member_schedule->get_first_date( $date );
166 $default_entries = $this->default_schedule ? $this->default_schedule->get_first_date( $date ) : array();
167
168 $all_slots = array();
169
170 // Handle the case when any schedule returns a single entry (backwards compatibility)
171 if ( ! is_array( $default_entries ) ) {
172 $default_entries = array( $default_entries );
173 }
174 if ( ! is_array( $service_entries ) ) {
175 $service_entries = array( $service_entries );
176 }
177 if ( ! is_array( $team_member_entries ) ) {
178 $team_member_entries = array( $team_member_entries );
179 }
180
181 // Remove null entries
182 $default_entries = array_filter( $default_entries );
183 $service_entries = array_filter( $service_entries );
184 $team_member_entries = array_filter( $team_member_entries );
185
186 // Get the appropriate strategy from factory and generate slots
187 $strategy = Time_Slot_Strategy_Factory::get_strategy(
188 $this->service_id,
189 $this->team_member_schedule->get_id() ?? null
190 );
191
192 $slots = $strategy->generate_slots(
193 $service_entries,
194 $team_member_entries,
195 $default_entries,
196 $this,
197 $date
198 );
199 $all_slots = array_merge( $all_slots, $slots );
200
201 // Remove duplicates and sort slots
202 $all_slots = $this->deduplicate_and_sort_slots( $all_slots );
203 $all_slots = $this->set_holiday_slots_as_empty_array( $all_slots );
204
205 return $all_slots;
206 }
207
208 /**
209 * Generate time slots for a date range.
210 *
211 * Creates an array of available time slots for each day in the given date range.
212 * The slots are marked as booked if they overlap with existing appointments.
213 *
214 * @param string $start_date Start date for the date range in Y-m-d format
215 * @param string $end_date End date for the date range in Y-m-d format
216 *
217 * @return array Collection of daily time slots indexed by date in Y-m-d format
218 * @throws Exception When date parsing fails or invalid date range is provided
219 */
220 public function generate_slots_for_range( string $start_date, string $end_date ): array {
221 // Prefetch Google Calendar events for the entire range (single API call)
222 $this->prefetch_google_calendar_events( $start_date, $end_date );
223
224 $slots_by_day = array();
225 $start = new DateTime( $start_date );
226 $end = new DateTime( $end_date );
227
228 while ( $start <= $end ) {
229 $daily_slots = $this->generate_slots( clone $start );
230 $slots_by_day[ $start->format( 'Y-m-d' ) ] = $this->mark_booked_slots( clone $start, $daily_slots );
231 $start->modify( '+1 day' );
232 }
233
234 $slots_by_day = $this->set_holiday_slots_as_empty_array( $slots_by_day );
235
236 return $slots_by_day;
237 }
238
239 /**
240 * Removes slots that fall on holidays from the given array of generated slots by date.
241 *
242 * @param array $slots Array of time slots to remove holiday slots from.
243 * @return array Array of time slots with holiday slots removed.
244 */
245 private function set_holiday_slots_as_empty_array( array $slots ): array {
246 $team_member_holidays = [];
247 $holidays = booktics_get_option( 'holiday' , []); // holidays from default settings
248 $team_member_id = $this->team_member_schedule->get_id() ?? null;
249
250 if ( $team_member_id ) {
251 $team_member_holidays = ( new Schedule_Record() )->get_schedules( $team_member_id )['holiday'] ?? [];
252 $team_member = ( new Team_Member_Model( $team_member_id ) );
253
254 // If team member has custom holidays enabled, merge them with default holidays
255 if ( $team_member->enable_custom_holiday ) {
256 $holidays = array_merge( $holidays, $team_member_holidays );
257 }
258 }
259
260 if ( empty( $holidays ) || empty( $slots ) ) {
261 return $slots;
262 }
263
264 foreach ( $slots as $slot_date => $slot_data ) {
265 if ( empty( $slot_data ) ) {
266 continue;
267 }
268
269 if ( ! is_array( $holidays ) || empty( $holidays ) ) {
270 break;
271 }
272
273 foreach ( $holidays as $holiday ) {
274 $formatted_slot_date = gmdate( 'Y-m-d', strtotime( $slot_date ) );
275 $holiday_start = gmdate( 'Y-m-d', strtotime( $holiday[0] ) );
276 $holiday_end = gmdate( 'Y-m-d', strtotime( $holiday[1] ) );
277
278 // if the slot date is between holiday range, remove it by setting slots as empty
279 if ( $formatted_slot_date >= $holiday_start && $formatted_slot_date <= $holiday_end ) {
280 $slots[ $slot_date ] = [];
281 break;
282 }
283 }
284 }
285
286 return $slots;
287 }
288
289 /**
290 * Get all available slots
291 *
292 * @param DateTime $date
293 *
294 * @return array
295 * @throws Exception
296 */
297 public function get_available_slots( DateTime $date ): array {
298 $date_str = $date->format( 'Y-m-d' );
299 $this->prefetch_google_calendar_events( $date_str, $date_str );
300
301 $slots = $this->generate_slots( $date );
302 $slots = $this->mark_booked_slots( $date, $slots );
303
304 return array_filter( $slots, fn( $slot ) => $slot->is_available );
305 }
306
307 /**
308 * Mark booked slots
309 *
310 * @param DateTime $date
311 * @param array $slots
312 *
313 * @return array
314 * @throws Exception
315 */
316 public function mark_booked_slots( DateTime $date, array $slots ): array {
317 $booked_slots = $this->get_booked_slots( $date );
318 foreach ( $slots as $slot ) {
319 $slot_start = strtotime( $slot->start );
320 $slot_end = strtotime( $slot->end );
321 $booked_count = 0;
322
323 foreach ( $booked_slots as $booked ) {
324 $booked_start = strtotime( $booked['start'] );
325 $booked_end = strtotime( $booked['end'] );
326 if ( $slot_start < $booked_end && $slot_end > $booked_start ) {
327 if ( (int) $booked['team_member_id'] === (int) $this->team_member_schedule->get_id() ) {
328 $attendees = ! empty( $booked['total_attendees'] ) ? (int) $booked['total_attendees'] : 1;
329 $booked_count += $attendees;
330 }
331 }
332 }
333
334 $slot->booked_count = $booked_count;
335 $slot->available_spots = max( 0, $slot->max_capacity - $booked_count );
336 $slot->is_available = $slot->available_spots >= $this->total_attendees;
337 }
338
339 return $slots;
340 }
341
342 /**
343 * Get booked slots
344 *
345 * @param DateTime $date
346 *
347 * @return array
348 * @throws Exception
349 */
350 private function get_booked_slots( DateTime $date ): array {
351 $team_member_id = $this->team_member_schedule->get_id();
352 $appointments = ( new Appointment_Model() )
353 ->get_appointments_for_slot(
354 $date->format( 'Y-m-d' ),
355 $team_member_id
356 );
357
358 $booked_slots = array_map(
359 function ( $appointment ) use ( $date ) {
360 $start_time = strtotime( $appointment->start_time );
361 $appointment_duration = 30;
362 if ( ! empty( $appointment->duration ) && (int) $appointment->duration > 0 ) {
363 $appointment_duration = (int) $appointment->duration;
364 }
365 $actual_end_time = $start_time + ( $appointment_duration * 60 );
366
367 return array(
368 'start' => gmdate( 'H:i', $start_time ),
369 'end' => gmdate( 'H:i', $actual_end_time ),
370 'service_id' => $appointment->service_id,
371 'team_member_id' => $appointment->team_member_id,
372 'total_attendees'=> $appointment->total_attendees ?? 1,
373 );
374 },
375 $appointments
376 );
377
378 // Merge cached Google Calendar booked slots for this date
379 $date_str = $date->format( 'Y-m-d' );
380 if ( is_array( $this->google_booked_slots_cache ) && ! empty( $this->google_booked_slots_cache[ $date_str ] ) ) {
381 $booked_slots = array_merge( $booked_slots, $this->google_booked_slots_cache[ $date_str ] );
382 }
383
384 return $booked_slots;
385 }
386
387 /**
388 * Prefetch Google Calendar events for an entire date range in a single API call.
389 * Groups results by date and stores in instance cache.
390 *
391 * @param string $start_date Start date in Y-m-d format.
392 * @param string $end_date End date in Y-m-d format.
393 */
394 private function prefetch_google_calendar_events( string $start_date, string $end_date ): void {
395 // Already fetched or feature disabled — skip
396 if ( null !== $this->google_booked_slots_cache ) {
397 return;
398 }
399
400 $this->google_booked_slots_cache = array();
401
402 $team_member_id = $this->team_member_schedule->get_id();
403 if ( ! $team_member_id ) {
404 return;
405 }
406
407 $block_enabled = booktics_get_option( 'block_booking_during_google_events', false );
408 if ( ! $block_enabled ) {
409 return;
410 }
411
412 if ( ! class_exists( Calendar::class ) ) {
413 return;
414 }
415
416 $calendar = new Calendar();
417 if ( ! $calendar->is_configured( (int) $team_member_id ) ) {
418 return;
419 }
420
421 $wp_timezone = wp_timezone_string();
422
423 try {
424 $range_start = new \DateTime( $start_date . ' 00:00:00', new \DateTimeZone( $wp_timezone ) );
425 $range_end = new \DateTime( $end_date . ' 23:59:59', new \DateTimeZone( $wp_timezone ) );
426 } catch ( \Exception $e ) {
427 return;
428 }
429
430 $api_filters = array(
431 'timeMin' => rawurlencode( $range_start->format( 'c' ) ),
432 'timeMax' => rawurlencode( $range_end->format( 'c' ) ),
433 );
434
435 $events = $calendar->get_google_calendar_events( (int) $team_member_id, $api_filters );
436
437 if ( is_wp_error( $events ) || empty( $events ) ) {
438 return;
439 }
440
441 // Collect all Booktics-synced Google event IDs to avoid double-blocking
442 $existing_event_ids = $this->get_all_synced_google_event_ids( $start_date, $end_date, $team_member_id );
443
444 foreach ( $events as $event ) {
445 // Skip events already synced as Booktics appointments
446 if ( isset( $event['id'] ) && in_array( $event['id'], $existing_event_ids, true ) ) {
447 continue;
448 }
449
450 // Skip all-day events (no dateTime key)
451 if ( empty( $event['start']['dateTime'] ) || empty( $event['end']['dateTime'] ) ) {
452 continue;
453 }
454
455 // Skip transparent (free) events — only block busy time
456 if ( isset( $event['transparency'] ) && 'transparent' === $event['transparency'] ) {
457 continue;
458 }
459
460 try {
461 $event_start = new \DateTime( $event['start']['dateTime'] );
462 $event_end = new \DateTime( $event['end']['dateTime'] );
463 } catch ( \Exception $e ) {
464 continue;
465 }
466
467 $event_date = $event_start->format( 'Y-m-d' );
468
469 if ( ! isset( $this->google_booked_slots_cache[ $event_date ] ) ) {
470 $this->google_booked_slots_cache[ $event_date ] = array();
471 }
472
473 $this->google_booked_slots_cache[ $event_date ][] = array(
474 'start' => $event_start->format( 'H:i' ),
475 'end' => $event_end->format( 'H:i' ),
476 'service_id' => 0,
477 'team_member_id' => $team_member_id,
478 'total_attendees' => 1,
479 );
480 }
481 }
482
483 /**
484 * Get all Google Calendar event IDs already linked to Booktics appointments in a date range.
485 *
486 * @param string $start_date Start date in Y-m-d format.
487 * @param string $end_date End date in Y-m-d format.
488 * @param int $team_member_id Team member user ID.
489 *
490 * @return array Array of Google Calendar event IDs.
491 */
492 private function get_all_synced_google_event_ids( string $start_date, string $end_date, $team_member_id ): array {
493 $args = array(
494 'post_type' => 'booktics-appointment',
495 'posts_per_page' => -1,
496 'fields' => 'ids',
497 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Required to find synced appointments in date range
498 'meta_query' => array(
499 'relation' => 'AND',
500 array(
501 'key' => 'date',
502 'value' => array( $start_date, $end_date ),
503 'compare' => 'BETWEEN',
504 'type' => 'DATE',
505 ),
506 array(
507 'key' => 'team_member_id',
508 'value' => $team_member_id,
509 'compare' => '=',
510 ),
511 array(
512 'key' => '_google_calendar_event_id',
513 'compare' => 'EXISTS',
514 ),
515 ),
516 );
517
518 $post_ids = get_posts( $args );
519 $event_ids = array();
520
521 foreach ( $post_ids as $post_id ) {
522 $event_id = get_post_meta( $post_id, '_google_calendar_event_id', true );
523 if ( ! empty( $event_id ) ) {
524 $event_ids[] = $event_id;
525 }
526 }
527
528 return $event_ids;
529 }
530
531 /**
532 * Get the length of time slots in minutes
533 *
534 * @return integer Slot length in minutes
535 */
536 public function get_slot_length(): int {
537 return $this->slot_length;
538 }
539
540 /**
541 * Get the service duration in minutes
542 *
543 * @return integer Service duration in minutes
544 */
545 public function get_service_duration(): int {
546 return $this->service_duration;
547 }
548
549 /**
550 * Get the before buffer time in minutes
551 *
552 * @return integer Before buffer time in minutes
553 */
554 public function get_before_buffer_time(): int {
555 return $this->before_buffer_time;
556 }
557
558 /**
559 * Get the after buffer time in minutes
560 *
561 * @return integer After buffer time in minutes
562 */
563 public function get_after_buffer_time(): int {
564 return $this->after_buffer_time;
565 }
566
567 /**
568 * Get the total slot duration including buffer times
569 *
570 * @return integer Total slot duration in minutes
571 */
572 public function get_total_slot_duration(): int {
573 return $this->service_duration + $this->before_buffer_time + $this->after_buffer_time;
574 }
575
576 /**
577 * Gets service max capacity for a single time slot
578 * Its used to get services group booking max capacity
579 */
580 public function get_service_max_capacity_for_timeslot(): int {
581 // For handling cases where bookitcs pro is not active/ available
582 if ( ! class_exists( '\BookticsPro\Models\Group_Booking_Model' ) ) {
583 return 1;
584 }
585
586 $group_bookings_list = ( new \BookticsPro\Models\Group_Booking_Model() )->where( 'service_id', $this->service_id );
587 if ( ! is_array( $group_bookings_list ) ) {
588 return 1;
589 }
590
591 $group_booking = $group_bookings_list[0];
592
593 if ( ! $group_booking || ! $group_booking->enable_group_booking ) {
594 return 1;
595 }
596
597 return $group_booking->max_booking ?? 1;
598 }
599 }
600