2026-03-26 12:07:55 +00:00

619 lines
20 KiB
PHP

<?php
/**
* Simply Schedule Appointments Availability Query.
*
* @since 3.6.2
* @package Simply_Schedule_Appointments
*/
use League\Period\Period;
/**
* Simply Schedule Appointments Availability Query.
*
* @since 3.6.2
*/
class SSA_Availability_Query {
/**
* Parent plugin class.
*
* @since 3.6.2
*
* @var Simply_Schedule_Appointments
*/
protected $period;
protected $appointment_type;
protected $args;
const args = array(
'staff_ids_all_required' => array(),
'staff_ids_any_required' => array(),
'resources_required' => array(),
// 'staff_required_count' => 1,
// 'location_ids_all_required' => -1,
// 'location_ids_some_required' => -1,
// 'any_location_count' => -1,
// 'resource_ids_all_required' => -1,
// 'resource_ids_some_required' => -1,
// 'any_resource_count' => -1,
'cache_level_read' => 1,
'cache_level_write' => 1,
'type' => '',
'subtype' => '',
'appointment_type' => true,
'appointment_type.min_booking_notice' => true,
'appointment_type.max_booking_notice' => true,
'appointment_type.availability_window' => true,
'appointment_type.max_per_day' => true,
'appointment_type.separate_appointment_type_availability' => true,
// 'appointment_type.booking_window' => true,
// 'appointment_type.buffers' => true,
// 'appointment_type.capacity' => true,
'appointment_type.appointments' => true,
'appointment_type.appointments.booked' => true,
'appointment_type.appointments.pending_form' => true,
'appointment_type.appointments.pending_payment' => true,
'blackout_dates' => true,
'google_calendar' => true,
'staff' => true,
// MemberPress integration
'mepr_membership_id' => 0,
'user_id' => 0,
'resources' => true,
'excluded_appointment_ids' => array(),
);
public static function get_default_args() {
$args = self::args;
unset( $args['cache_level_write'] );
unset( $args['cache_level_read'] );
return $args;
}
protected $schedule;
protected $staff_schedules;
protected $_queried_appointments;
protected $_booked_group_appointments;
public static function create( SSA_Appointment_Type_Object $appointment_type, Period $period, $args = array() ) {
$instance = new self( $appointment_type, $period, $args );
return $instance;
}
/**
* Constructor.
*
* @since 3.6.2
*
* @param Simply_Schedule_Appointments $plugin Main plugin object.
*/
public function __construct(
SSA_Appointment_Type_Object $appointment_type, Period $period, $args = array()
) {
$this->period = $period;
$this->args = array_merge( self::args, $args );
$this->appointment_type = $appointment_type;
}
public function get_query_args() {
return array(
'period' => $this->period,
'args' => $this->args,
'appointment_type_id' => $this->appointment_type->id,
'query_hash' => $this->get_query_hash(),
);
}
public function get_query_hash() {
$args = $this->args;
unset( $args['cache_level_write'] );
unset( $args['cache_level_read'] );
$json = json_encode( array(
'period' => $this->period,
'args' => $args,
'appointment_type' => $this->appointment_type->id,
) );
return ssa_int_hash( $json );
}
public function get_schedule_for_staff_id( int $staff_id, $args = array() ) {
if ( ! empty( $this->staff_schedules[$staff_id] ) ) {
$this->staff_schedules[$staff_id];
}
$staff = new SSA_Staff_Object( $staff_id );
$this->staff_schedules[$staff_id] = $staff->get_schedule( $this->appointment_type, $this->period, $args );
return $this->staff_schedules[$staff_id];
}
public function get_schedule( $args = array() ) {
if ( null !== $this->schedule ) {
return $this->schedule;
}
$args = array_merge( $this->args, $args );
$chunk_interval = new DateInterval( 'P7D' );
$maximum_chunk_period = $this->period->withDurationAfterStart( $chunk_interval );
if ( $this->period->durationGreaterThan( $maximum_chunk_period ) ) {
$schedule = new SSA_Availability_Schedule();
foreach ( $this->period->split( $chunk_interval ) as $chunk_period ) {
$chunk_query = new SSA_Availability_Query(
$this->appointment_type,
$chunk_period,
$this->args
);
$chunk_schedule = $chunk_query->get_schedule( $args );
$schedule = $schedule->pushmerge( $chunk_schedule->get_blocks() );
}
$this->schedule = $schedule;
return $this->schedule;
}
$query_period = $this->period;
$this->period = new Period(
$this->period->getStartDate()->sub($this->appointment_type->get_buffered_duration_interval() ),
$this->period->getEndDate()->add( $this->appointment_type->get_buffered_duration_interval() )
);
$this->schedule = $this->appointment_type->get_schedule( $this->period, $args );
if ( ! empty( $args['blackout_dates'] ) ) {
$blackout_dates_schedule = ssa()->blackout_dates->get_schedule( $this->appointment_type, $this->period, $args );
if ( null !== $blackout_dates_schedule ) {
$this->schedule = $this->schedule->merge_min( $blackout_dates_schedule );
}
}
if ( empty( $this->appointment_type ) ) {
return $this->schedule;
}
if ( ! empty( $args['google_calendar'] ) ) {
$google_calendar_schedule = ssa()->google_calendar->get_schedule( $this->appointment_type, $this->period, $args );
if ( null !== $google_calendar_schedule ) {
$google_calendar_schedule = $google_calendar_schedule->subrange( $this->period );
$this->schedule = $this->schedule->merge_min( $google_calendar_schedule );
}
}
if ( ! empty( $args['staff'] ) ) {
if ( ssa()->settings_installed->is_activated( 'staff' ) ) {
$staff_schedule = ssa()->staff->get_schedule( $this->appointment_type, $this->period, $args );
if ( null !== $staff_schedule ) {
$this->schedule = $this->schedule->merge_min( $staff_schedule );
}
}
}
if ( ! empty( $args['mepr_membership_id'] ) && class_exists( 'SSA_Memberpress' ) ) {
$membership_schedule = ssa()->memberpress->get_schedule( $this->appointment_type, $this->period, $args );
if ( null !== $membership_schedule ) {
$this->schedule = $this->schedule->merge_min( $membership_schedule );
}
}
if ( ! empty( $args['resources'] ) ) {
if ( ssa()->settings_installed->is_activated( 'resources' ) ) {
$resource_schedule = ssa()->resources->get_schedule( $this->appointment_type, $this->period, $args );
if ( null !== $resource_schedule ) {
$this->schedule = $this->schedule->merge_min( $resource_schedule );
}
}
}
if ( ! empty( $args['excluded_appointment_ids'] ) ) {
$excluded_appointments_schedule = $this->get_excluded_appointments_schedule( $this->appointment_type, $this->period, $args );
if ( null !== $excluded_appointments_schedule ) {
$this->schedule = $this->schedule->merge_max( $excluded_appointments_schedule );
}
}
$this->schedule = $this->schedule->subrange( $query_period ); // cut off the edges
$this->period = $query_period;
return $this->schedule;
}
public function get_excluded_appointments_schedule(SSA_Appointment_Type_Object $appointment_type, Period $query_period, $args) {
if ( empty( $args['excluded_appointment_ids'] ) ) {
return;
}
$query_period = SSA_Utils::get_query_period( $query_period );
$schedule = new SSA_Availability_Schedule();
foreach ( $args['excluded_appointment_ids'] as $appointment_id ) {
$appointment = new SSA_Appointment_Object( $appointment_id );
$period = $appointment->get_appointment_period();
if (!$query_period->overlaps($period)) {
continue;
}
$schedule = $schedule->pushmerge( SSA_Availability_Block_Factory::available_for_period( $period, array(
'capacity_available' => 1,
'buffer_available' => SSA_Constants::CAPACITY_MAX
) ) );
}
return $schedule;
}
public function why_not_bookable() {
// TODO // so we can return an error code "blackout dates"
}
public function get_bookable_appointments() {
if ( empty( $this->appointment_type ) ) {
throw new SSA_Exception( 'Appointment Type required' );
}
$schedule = $this->get_schedule();
$bookable_appointments = array();
$availability_interval = $this->appointment_type->get_availability_interval();
$availability_increment = $this->appointment_type->availability_increment;
if ( ! $availability_interval instanceof DateInterval ) {
$availability_interval = new DateInterval( 'PT15M' );
}
$availability = $this->appointment_type->availability;
$timezone = $this->appointment_type->get_timezone();
$duration = $this->appointment_type->duration;
$capacity_type = $this->appointment_type->capacity_type;
if ( 'group' === $capacity_type ) {
$booked_group_appointments = $this->appointment_type->get_appointment_objects( $this->period, array(
'status' => SSA_Appointment_Model::get_unavailable_statuses(),
) );
}
foreach ($schedule->get_blocks() as $block) {
if ( $block->capacity_available <= 0 ) {
continue;
}
$starting_minute = (int)$block->get_period()->getStartDate()->setTimezone( $timezone )->format( 'i' );
$minutes_to_add = 0;
while( ( $starting_minute + $minutes_to_add ) % $availability_increment !== 0 ) {
$minutes_to_add++;
}
if ( $minutes_to_add ) {
$start_date = $block->get_period()->getStartDate();
$end_date = $block->get_period()->getEndDate();
$new_start_date = $start_date->add( new DateInterval( 'PT'.$minutes_to_add.'M' ) );
if ( $new_start_date >= $end_date ) {
continue;
}
$block = $block->set_period( new Period( $new_start_date, $end_date ) );
}
foreach ( $block->get_period()->split( $availability_interval ) as $period ) {
if ( 'start_times' === $this->appointment_type->availability_type ) {
$start_datetime_tz = $period->getStartDate()->setTimezone( $timezone );
$day_of_week = $start_datetime_tz->format( 'l' );
if ( empty( $availability[$day_of_week]['0']['time_start'] ) ) {
continue; // no start times set for this day of the week
}
$is_available_start_time = false;
foreach ( $availability[$day_of_week] as $availability_value ) {
if ( $availability_value['time_start'] === $start_datetime_tz->format( 'H:i:s' ) ) {
$is_available_start_time = true;
break;
}
}
if ( ! $is_available_start_time ) {
continue;
}
}
if ( 'group' === $capacity_type ) {
foreach ( $booked_group_appointments as $booked_group_appointment ) {
if ( $booked_group_appointment->get_buffered_period()->overlaps( $period ) ) {
if ( $booked_group_appointment->get_appointment_period()->getStartDate() != $period->getStartDate() ) {
continue 2;
}
}
}
}
$appointment = SSA_Appointment_Factory::create( $this->appointment_type, array(
'id' => 0,
'start_date' => $period->getStartDate()->format( 'Y-m-d H:i:s' ),
) );
if ( ! $this->is_prospective_appointment_bookable( $appointment ) ) {
continue;
}
$bookable_appointments[] = $appointment;
}
}
return $bookable_appointments;
}
public function get_bookable_appointment_periods() {
$bookable_appointment_periods = array();
$bookable_appointments = $this->get_bookable_appointments();
foreach ($bookable_appointments as $appointment) {
$bookable_appointment_periods[] = $appointment->get_appointment_period();
}
return $bookable_appointment_periods;
}
public function get_bookable_appointment_start_datetime_strings_plucked() {
$bookable_start_datetime_strings = $this->get_bookable_appointment_start_datetime_strings();
if ( empty( $bookable_start_datetime_strings['0']['start_date'] ) ) {
return array();
}
$bookable_start_datetime_strings = wp_list_pluck( $bookable_start_datetime_strings, 'start_date' );
return $bookable_start_datetime_strings;
}
public function get_bookable_appointment_start_datetime_strings() {
$query_hash = $this->get_query_hash();
$cache_key = 'availability/'.$query_hash.'/bookable_datetime_strings';
if ( ! empty( $this->args['cache_level_read'] ) && $this->args['cache_level_read'] == 1 ) {
if ( ssa()->availability_cache->is_enabled() ) {
$cached = ssa_cache_get( $cache_key );
if ( $cached !== false && is_array( $cached ) ) {
if ( ! current_user_can( 'ssa_manage_site_settings' ) ) {
foreach ($cached as &$value) {
if ( isset( $value['capacity_available'] ) ) {
unset( $value['capacity_available'] );
}
}
}
$excluded_start_datetimes = ssa_cache_get('availability/appointment_type/' . $this->appointment_type->id . '/excluded_start_datetimes');
if ( ! empty($excluded_start_datetimes) ) {
$cached = array_filter($cached, function( $value ) use ( $excluded_start_datetimes ) {
return ! in_array( $value['start_date'], $excluded_start_datetimes );
});
$cached = array_values( $cached ); // needed to keep JSON response an array
}
return $cached;
}
}
}
$developer_settings = ssa()->developer_settings->get();
$bookable_start_datetimes = $this->get_bookable_appointment_start_datetimes();
$data = array();
$schedule = $this->get_schedule();
foreach ($bookable_start_datetimes as $start_datetime) {
$data_array = array(
'start_date' => $start_datetime->format('Y-m-d H:i:s'),
);
if ( ! empty( $developer_settings['display_capacity_available'] ) ) {
$block = $schedule->get_block_for_date( $start_datetime->format('Y-m-d H:i:s') );
if ( false === $block ) {
ssa_debug_log( 'get_bookable_appointment_start_datetime_strings(): block not found in schedule: ' . $start_datetime->format( 'Y-m-d H:i:s' ) );
} else {
$data_array['capacity_available'] = $block->capacity_available;
}
}
$data[] = $data_array;
}
if ( ! empty( $this->args['cache_level_write'] ) && $this->args['cache_level_write'] == 1 ) {
if ( ssa()->availability_cache->is_enabled() ) {
ssa_cache_set(
$cache_key,
$data
);
ssa()->availability_cache->remember_recent( 'availability_query_args', $this->get_query_args(), 10 );
}
}
if ( ! current_user_can( 'ssa_manage_site_settings' ) ) {
foreach ($data as &$value) {
if ( isset( $value['capacity_available'] ) ) {
unset( $value['capacity_available'] );
}
}
}
return $data;
}
public function get_bookable_appointment_start_datetimes() {
$bookable_appointment_start_datetimes = array();
$bookable_appointments = $this->get_bookable_appointments();
foreach ($bookable_appointments as $appointment) {
$bookable_appointment_start_datetimes[] = $appointment->get_appointment_period()->getStartDate();
}
return $bookable_appointment_start_datetimes;
}
public function get_queried_appointments() {
if ( null !== $this->_queried_appointments ) {
return $this->_queried_appointments;
}
$queried_appointments_array = $this->get_queried_appointments_array();
$queried_appointments = array();
foreach ($queried_appointments_array as $queried_appointment) {
$queried_appointment = SSA_Appointment_Object::instance( $queried_appointment );
$queried_appointments[] = $queried_appointment;
}
$this->_queried_appointments = $queried_appointments;
return $this->_queried_appointments;
}
public function get_queried_appointments_array() {
$args = array(
'number' => -1,
'orderby' => 'start_date',
// 'appointment_type_id' => $appointment_type->id,
'intersects_period' => $this->period,
'status' => SSA_Appointment_Model::get_unavailable_statuses(),
);
$queried_appointments_array = ssa()->appointment_model->query( $args );
return $queried_appointments_array;
}
public function get_booked_group_appointments() {
if ( null !== $this->_booked_group_appointments ) {
return $this->_booked_group_appointments;
}
if ( null === $this->appointment_type ) {
return;
}
$this->_booked_group_appointments = $this->appointment_type->get_appointment_objects( $this->period, array(
'status' => SSA_Appointment_Model::get_unavailable_statuses(),
) );
return $this->_booked_group_appointments;
}
public function is_prospective_appointment_bookable( SSA_Appointment_Object $appointment ) {
global $ssa_staff_unavailable_start_dates;
$schedule = $this->get_schedule( array(
'skip_appointment_id' => $appointment->id,
) );
if ( null === $schedule ) {
return false;
}
if ( ! empty( $this->appointment_type ) ) {
// We should use the appointment type that we're querying for if it's set (faster than getting appointment type dynamically each run)
$appointment_type = $this->appointment_type;
} else {
$appointment_type = $appointment->get_appointment_type();
}
if ( ! empty( $ssa_staff_unavailable_start_dates[$appointment->start_date] ) ) {
return false; // staff member is unavailable at this time
}
if ( ! $schedule->is_appointment_period_available( $appointment, $appointment_type ) ) {
return false;
}
$appointment_buffered_period = $appointment->get_buffered_period();
$appointment_period = $appointment->get_appointment_period();
if ( 'group' === $appointment_type->capacity_type ) {
$booked_group_appointments = $this->get_booked_group_appointments();
if ( ! empty( $booked_group_appointments ) ) {
foreach ( $booked_group_appointments as $booked_group_appointment ) {
if ( $booked_group_appointment->get_buffered_period()->overlaps( $appointment_buffered_period ) ) {
// There might be a potential conflict
if ( $booked_group_appointment->get_appointment_period()->getStartDate() != $appointment_period->getStartDate() ) {
// And we've confirmed it's not the exact same start time (since that would be allowed)
if ( $booked_group_appointment->get_buffered_period()->overlaps( $appointment_period ) ) {
return false;
}
if ( $booked_group_appointment->get_appointment_period()->overlaps( $appointment_buffered_period ) ) {
return false;
}
}
}
}
}
}
// If `any` team member is selected, we need to check each team member since the merge_max can't cover every scenario
if (!empty($this->args['staff'])) {
if (ssa()->settings_installed->is_activated('staff')) {
$appointment_type_staff_settings = $appointment_type->staff;
if ( ! empty( $appointment_type_staff_settings ) && ! empty( $appointment_type_staff_settings['required'] ) ) {
if ( 'any' === $appointment_type_staff_settings['required'] ) {
// static $total;
// static $unavailable;
// ssa_debug_log( $total++ . ': Trying to find staff member for ' . $appointment->start_date, 10 );
// proactive and effective, but very slow (about 4x slower):
// TODO: only run the following block in async mode so the slow-building cache can be as correct as possible
// $data = ssa()->staff->assign_appointment_to_staff( array(
// 'appointment_type_id' => $appointment_type, // the assign_appointment_to_staff function will use the SSA_Appointment_Type object we're passing as `appointment_type_id` and save a lookup step
// 'start_date' => $appointment->start_date,
// // 'availability_query' => $this,
// ) );
// if ( empty( $data['staff_ids'] ) ) {
// // assigning to a staff member would end up empty, so we shouldn't allow this appointment to be booked
// ssa_debug_log($unavailable++ . ' UNAVAILABLE: Trying to find staff member for ' . $appointment->start_date, 10);
// return false;
// }
}
}
}
}
// Other Appointment Types Shared Availability
$developer_settings = ssa()->developer_settings->get();
$separate_appointment_type_availability = $developer_settings['separate_appointment_type_availability'];
if ( empty( $this->args['appointment_type.separate_appointment_type_availability']) || $separate_appointment_type_availability ) {
return true;
}
// TODO: use cached appointment schedule for all other appointment types?
$queried_appointments = $this->get_queried_appointments();
if ( empty( $queried_appointments ) ) {
return true;
}
foreach ($queried_appointments as $queried_appointment) {
if ( $queried_appointment->appointment_type_id == $appointment_type->id ) {
continue; // this is already checked and accounted for in the appointment_type object's get_schedule() function
}
if ( $appointment_buffered_period->overlaps( $queried_appointment->get_appointment_period() )) {
return false;
}
if ( $appointment_period->overlaps( $queried_appointment->get_buffered_period() ) ) {
return false;
}
}
return true;
}
}