2026-03-27 11:54:51 +00:00

844 lines
23 KiB
PHP

<?php
/**
* Simply Schedule Appointments Availability Schedule.
*
* @since 3.6.10
* @package Simply_Schedule_Appointments
*/
use League\Period\Period;
/**
* Simply Schedule Appointments Availability Schedule.
*
* @since 3.6.10
*/
class SSA_Availability_Schedule implements Countable, Iterator {
/**
* Parent plugin class.
*
* @since 3.6.10
*
* @var Simply_Schedule_Appointments
*/
protected $plugin = null;
protected $blocks = array();
protected $is_sorted = true;
protected $new_blocks = array();
protected $count = 0;
protected $position = 0;
protected $_queried_appointments = null;
// Dynamic property - can be set by factory
public $capacity_available;
const MERGE_MODE_MIN = -1;
const MERGE_MODE_MAX = 1;
protected $merge_mode = self::MERGE_MODE_MIN;
/**
* Constructor.
*
* @since 3.6.10
*
* @param Simply_Schedule_Appointments $plugin Main plugin object.
*/
public function __construct() {
}
public function get_keyed_blocks_array( $blocks ) {
// check if the blocks are already keyed
if ( ! empty( $blocks ) ) {
$first_block = reset( $blocks );
$first_start_date = $first_block->get_period()->getStartDate()->format( 'Y-m-d H:i:s' );
if ( ! empty( $blocks[$first_start_date] ) ) {
return $blocks;
}
}
// build the keyed blocks array
$keyed_blocks = array();
foreach ($blocks as $block) {
$keyed_blocks[$block->get_period()->getStartDate()->format( 'Y-m-d H:i:s' )] = $block;
}
return $keyed_blocks;
}
public function set_blocks( $blocks, $is_clean = false ) {
$clone = $this->get_clone();
// get the first block and see if its array key matches that block's period start date
$blocks = $this->get_keyed_blocks_array( $blocks );
$clone->blocks = $blocks;
$clone->is_sorted = $is_clean;
return $clone;
// $this->is_clean = $is_clean; // sorted, gapless, overlapless
}
public function get_blocks() {
if ( $this->is_sorted() ) {
return $this->blocks;
}
$this->blocks = $this->sort()->blocks;
$this->is_sorted = true;
return $this->blocks;
}
public function overlaps( SSA_Availability_Schedule $schedule ) {
$this_boundaries = $this->boundaries();
if ( empty( $this_boundaries ) ) {
return false;
}
$schedule_boundaries = $schedule->boundaries();
if ( empty( $schedule_boundaries ) ) {
return false;
}
return $this_boundaries->overlaps( $schedule_boundaries );
}
public function subrange( Period $period, $exact = true ) {
if ( $this->boundaries() == $period ) {
return $this;
}
$filter_function = function ( $value ) use ( $period ) {
if ( $value->get_period()->overlaps( $period ) ) {
return true;
}
return false;
};
$overlapping_schedule = $this->filter( $filter_function );
if ( empty( $exact ) ) {
return $overlapping_schedule;
}
$blocks = $overlapping_schedule->get_blocks();
while ( ! empty( $blocks ) && reset( $blocks )->get_period()->getStartDate() < $period->getStartDate() ) {
$block = array_shift( $blocks );
if ( $block->get_period()->getEndDate() <= $period->getStartDate() ) {
continue; // this block begins and ends before the desired time, so we should just toss it rather than modify it
}
$block = $block->set_period( new Period(
$period->getStartDate(),
$block->get_period()->getEndDate()
) );
array_unshift( $blocks, $block );
}
while ( ! empty( $blocks ) && end( $blocks )->get_period()->getEndDate() > $period->getEndDate() ) {
$block = array_pop( $blocks );
if ( $block->get_period()->getStartDate() >= $period->getEndDate() ) {
continue; // this block begins and ends after the desired time, so we should just toss it rather than modify it
}
$block = $block->set_period( new Period(
$block->get_period()->getStartDate(),
$period->getEndDate()
) );
$blocks[] = $block;
}
$exact_schedule = $this->set_blocks( $blocks, true );
return $exact_schedule;
}
public function binarize( $gte_threshold = 1 ) {
$schedule = new SSA_Availability_Schedule();
foreach ($this->get_blocks() as $block) {
$schedule = $schedule->pushmerge( $block->binarize( $gte_threshold ) );
}
return $schedule;
}
// public function blackout_by_capacity_reserved_gte( int $capacity_reserved ) {
// $schedule = $this->set_blocks(array(), true);
// foreach ($this->get_blocks() as $block) {
// echo '<pre>'.print_r($block, true).'</pre>';
// if ( $block->capacity_reserved >= $capacity_reserved ) {
// $block->capacity_available = 0;
// $schedule = $schedule->pushmerge( $block );
// }
// }
// return $schedule;
// }
/**
* Filters the sequence according to the given predicate.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the interval which validate the predicate.
*/
public function map( callable $predicate ) {
$blocks = $this->get_blocks();
$mapped_blocks = array_map( $predicate, $blocks );
$mapped_schedule = new SSA_Availability_Schedule();
foreach ($mapped_blocks as $mapped_block) {
$mapped_schedule = $mapped_schedule->pushmerge( $mapped_block );
}
return $mapped_schedule;
}
/**
* Filters the sequence according to the given predicate.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the interval which validate the predicate.
*/
public function filter( callable $predicate ) {
$blocks = $this->get_blocks();
$filtered_blocks = array_filter( $blocks, $predicate, ARRAY_FILTER_USE_BOTH );
if ( $filtered_blocks === $blocks ) {
return $this;
}
return $this->set_blocks( $filtered_blocks );
}
/**
* Returns an instance sorted according to the given comparison callable
* but does not maintain index association.
*
* This method DOES NOT retain the state of the current instance, it sorts the original
*/
public function sort( callable $compare = null ) {
if ( $this->is_sorted() ) {
return $this;
}
$blocks = $this->blocks;
ksort( $blocks );
$new_instance = $this->set_blocks( $blocks, true );
return $new_instance;
}
private function get_clone() {
$instance = clone $this;
$instance->_queried_appointments = null;
return $instance;
}
public function boundaries() {
if ( $this->is_empty() ) {
return null;
}
$blocks = $this->get_blocks();
if ( 1 === count( $blocks ) ) {
$first_block = reset( $blocks );
return $first_block->get_period();
}
$start_date = array_shift( $blocks )->get_period()->getStartDate();
$end_date = array_pop( $blocks )->get_period()->getEndDate();
return new Period( $start_date, $end_date );
}
/**
* Sorts two Interval instance using their start datepoint.
*/
private function sort_by_start_date(SSA_Availability_Block $block1, SSA_Availability_Block $block2) {
$a = $block1->get_period()->getStartDate();
$b = $block2->get_period()->getStartDate();
if ( $a == $b ) {
return 0;
} else if ( $a < $b ) {
return -1;
} else if ( $a > $b ) {
return 1;
}
return null;
}
private function reconcile_new_blocks() {
if ( empty( $this->new_blocks ) || ! is_array( $this->new_blocks ) ) {
return;
}
foreach ($this->new_blocks as $key => $block) {
$schedule = $this->add_block( $block );
}
}
public function add_block( SSA_Availability_Block $new_block ) {
$boundaries = $this->boundaries();
// Let's try to handle the simple (non-overlapping) cases first
if ( $this->is_empty() ) {
$reconciled_schedule = $this->push( $new_block );
return $reconciled_schedule;
} else if ( $new_block->is_after_period( $boundaries ) ) {
$reconciled_schedule = $this->pushmerge( $new_block );
return $reconciled_schedule;
} else if ( $new_block->is_before_period( $boundaries ) ) {
$reconciled_schedule = $this->unshiftmerge( $new_block );
return $reconciled_schedule;
} else if ( ! $new_block->overlaps_period( $boundaries ) ) {
throw new Exception("unhandled case in add_block()", 1);
}
// we must be dealing with an overlapping block (the complex case)
$reconciled_schedule = $this->add_overlapping_block( $new_block );
return $reconciled_schedule;
}
private function add_overlapping_block( SSA_Availability_Block $new_block ) {
$reconciled_schedule = $this->set_blocks( array() );
$has_added_reconciled_blocks = false;
foreach ($this->get_blocks() as $key => $original_block) {
if ( $has_added_reconciled_blocks ) {
$reconciled_schedule = $reconciled_schedule->add_block( $original_block );
continue;
}
if ( ! $has_added_reconciled_blocks &&
! $original_block->get_period()->overlaps( $new_block->get_period() )
) {
$reconciled_schedule = $reconciled_schedule->push( $original_block );
continue;
}
$reconciled_blocks = array();
if ( $original_block->contains( $new_block ) ) {
// Add Contained Block
$left_block = $original_block;
$right_block = $original_block;
} else if ( $new_block->contains( $original_block ) ) {
// Add Container Block
$left_block = $new_block;
$right_block = $new_block;
} else if ( $new_block->overlaps( $original_block ) ) {
if ( $new_block->get_period()->getStartDate() < $original_block->get_period()->getStartDate() ) {
// Add overlapping earlier block
$left_block = $new_block;
$right_block = $original_block;
} else if ( $new_block->get_period()->getStartDate() >= $original_block->get_period()->getStartDate() ) {
// Add overlapping later block
$left_block = $original_block;
$right_block = $new_block;
}
} else {
// we shouldn't even be in this function unless the blocks overlap
throw new Exception("unhandled case in add_overlapping_block()", 1);
}
$middle_block = null;
$diff_array = $original_block->get_period()->diff( $new_block->get_period() );
$intersect_period = $original_block->get_period()->intersect( $new_block->get_period() );
if ( ! empty( $diff_array[0] ) ) {
$reconciled_blocks[] = $left_block->set_period( $diff_array[0] );
}
if ( ! empty( $intersect_period ) ) {
if ( self::MERGE_MODE_MIN === $this->merge_mode ) {
$reconciled_blocks[] = $this->min_reconcile_overlapping_blocks_intersection(
$original_block,
$new_block,
$intersect_period
);
} else if ( self::MERGE_MODE_MAX === $this->merge_mode ) {
$reconciled_blocks[] = $this->max_reconcile_overlapping_blocks_intersection(
$original_block,
$new_block,
$intersect_period
);
}
}
if ( ! empty( $diff_array[1] ) ) {
$reconciled_blocks[] = $right_block->set_period( $diff_array[1] );
}
$schedule_to_merge = $this->set_blocks( $reconciled_blocks )->cleaned();
$reconciled_schedule = $reconciled_schedule->merge( $schedule_to_merge );
$has_added_reconciled_blocks = true;
}
return $reconciled_schedule;
}
public function min_reconcile_overlapping_blocks_intersection( SSA_Availability_Block $original_block, SSA_Availability_Block $new_block, Period $intersect_period ) {
$intersect_block = new SSA_Availability_Block();
$intersect_block->period = $intersect_period;
$intersect_block->capacity_reserved = max(
$original_block->capacity_reserved,
$new_block->capacity_reserved
);
$intersect_block->capacity_available = min(
$original_block->capacity_available,
$new_block->capacity_available
);
$capacity_reserved_delta = $original_block->capacity_reserved_delta + $new_block->capacity_reserved_delta;
$intersect_block->buffer_reserved = max(
$original_block->buffer_reserved,
$new_block->buffer_reserved
);
$intersect_block->buffer_available = min(
$original_block->buffer_available,
$new_block->buffer_available
);
$buffer_reserved_delta = $original_block->buffer_reserved_delta + $new_block->buffer_reserved_delta;
if ( $intersect_block->capacity_available < SSA_Constants::CAPACITY_MAX ) {
$intersect_block->capacity_available -= $capacity_reserved_delta;
$intersect_block->capacity_reserved += $capacity_reserved_delta;
}
if ( $intersect_block->buffer_available < SSA_Constants::CAPACITY_MAX ) {
$intersect_block->buffer_available -= $buffer_reserved_delta;
$intersect_block->buffer_reserved += $buffer_reserved_delta;
}
return $intersect_block;
}
public function max_reconcile_overlapping_blocks_intersection( SSA_Availability_Block $original_block, SSA_Availability_Block $new_block, Period $intersect_period ) {
$intersect_block = new SSA_Availability_Block();
$intersect_block->period = $intersect_period;
$intersect_block->capacity_reserved = min(
$original_block->capacity_reserved,
$new_block->capacity_reserved
);
$intersect_block->capacity_available = max(
$original_block->capacity_available,
$new_block->capacity_available
);
$capacity_reserved_delta = $original_block->capacity_reserved_delta + $new_block->capacity_reserved_delta;
$intersect_block->buffer_reserved = max(
$original_block->buffer_reserved,
$new_block->buffer_reserved
);
$intersect_block->buffer_available = max(
$original_block->buffer_available,
$new_block->buffer_available
);
$buffer_reserved_delta = $original_block->buffer_reserved_delta + $new_block->buffer_reserved_delta;
if ( $intersect_block->capacity_available < SSA_Constants::CAPACITY_MAX ) {
$intersect_block->capacity_available -= $capacity_reserved_delta;
$intersect_block->capacity_reserved += $capacity_reserved_delta;
}
if ( $intersect_block->buffer_available < SSA_Constants::CAPACITY_MAX ) {
$intersect_block->buffer_available -= $buffer_reserved_delta;
$intersect_block->buffer_reserved += $buffer_reserved_delta;
}
// extra computation and not actually helpful?:
// if ( $intersect_block->capacity_reserved > 0 && $intersect_block->capacity_available <= 0 ) {
// $intersect_block->buffer_available = 0;
// }
return $intersect_block;
}
public function get_criteria() {
}
public function is_empty() {
return array() === $this->blocks;
}
public function is_sorted() {
return $this->is_sorted;
}
public function is_continuous() {
if ( empty( $this->blocks ) ) {
return false;
}
$last_block = null;
foreach ($this->get_blocks() as $key => $block) {
if ( ! empty( $last_block ) ) {
if ( ! $block->abuts( $last_block ) ) {
return false;
}
}
$last_block = $block;
}
return true;
}
/**
* Adds new blocks at the end of the sequence.
*
* @param array of SSA_Availability_Block $blocks
*/
public function push( $blocks ) {
if ( ! is_array( $blocks ) ) {
$blocks = array( $blocks );
}
$clone = $this->get_clone();
foreach ( $blocks as $block ) {
$start_date = $block->get_period()->getStartDate()->format( 'Y-m-d H:i:s' );
if ( ! empty( $clone->blocks[$start_date] ) ) {
ssa_debug_log( 'DUPLICATE BLOCK! ' . $start_date, 10 );
}
$clone->blocks[$start_date] = $block;
}
$clone->is_sorted = false;
return $clone;
}
/**
* Adds new blocks at the end of the sequence.
*
* @param array of SSA_Availability_Block $blocks
*/
public function pushmerge( $new_blocks ) {
if ( ! is_array( $new_blocks ) ) {
$new_blocks = array( $new_blocks );
}
$remaining_new_blocks = $new_blocks;
$first_new_block = array_shift( $remaining_new_blocks );
if ( empty( $first_new_block ) ) {
return $this;
}
$reconciled_schedule_blocks = $this->get_blocks();
$last_block = array_pop( $reconciled_schedule_blocks );
if ( empty( $last_block ) ) {
return $this->push( $new_blocks );
}
if ( ! $last_block->can_merge( $first_new_block ) ) {
return $this->push( $new_blocks );
}
$reconciled_schedule = $this->set_blocks( $reconciled_schedule_blocks );
$reconciled_schedule = $reconciled_schedule->push( $last_block->merge( $first_new_block ) );
if ( empty( $remaining_new_blocks ) ) {
return $reconciled_schedule;
}
$reconciled_schedule = $reconciled_schedule->push( $remaining_new_blocks );
return $reconciled_schedule;
}
/**
* Adds new blocks at the beginning of the sequence (and does the extra work to prevent abutting mergeable blocks)
*
* @param array of SSA_Availability_Block $blocks
*/
public function unshift( $blocks ) {
if ( ! is_array( $blocks ) ) {
$blocks = array( $blocks );
}
$clone = $this->get_clone();
foreach ( $blocks as $block ) {
$start_date = $block->get_period()->getStartDate()->format( 'Y-m-d H:i:s' );
if ( ! empty( $clone->blocks[$start_date] ) ) {
ssa_debug_log( 'DUPLICATE BLOCK! ' . $start_date, 10 );
}
$clone->blocks[$start_date] = $block;
}
$clone->is_sorted = false;
return $clone;
}
/**
* Adds new blocks at the beginning of the sequence (and does the extra work to prevent abutting mergeable blocks)
*
* @param array of SSA_Availability_Block $blocks
*/
public function unshiftmerge( $new_blocks ) {
if ( ! is_array( $new_blocks ) ) {
$new_blocks = array( $new_blocks );
}
$remaining_new_blocks = $new_blocks;
$last_new_block = array_shift( $remaining_new_blocks );
if ( empty( $last_new_block ) ) {
return $this;
}
$reconciled_schedule_blocks = $this->get_blocks();
$first_old_block = array_shift( $reconciled_schedule_blocks );
if ( empty( $first_old_block ) ) {
return $this->unshift( $new_blocks );
}
if ( ! $first_old_block->can_merge( $last_new_block ) ) {
return $this->unshift( $new_blocks );
}
$reconciled_schedule = $this->set_blocks( $reconciled_schedule_blocks );
$reconciled_schedule = $reconciled_schedule->unshift( $first_old_block->merge( $last_new_block ) );
if ( empty( $remaining_new_blocks ) ) {
return $reconciled_schedule;
}
$reconciled_schedule = $reconciled_schedule->unshift( $remaining_new_blocks );
return $reconciled_schedule;
}
public function merge_min( SSA_Availability_Schedule $another_schedule ) {
$this->merge_mode = self::MERGE_MODE_MIN;
$merged_schedule = $this->merge( $another_schedule );
return $merged_schedule;
}
public function merge_max( SSA_Availability_Schedule $another_schedule ) {
$this->merge_mode = self::MERGE_MODE_MAX;
$merged_schedule = $this->merge( $another_schedule );
$this->merge_mode = self::MERGE_MODE_MIN;
return $merged_schedule;
}
private function merge( SSA_Availability_Schedule $another_schedule ) {
if ( null === $another_schedule ) {
return $this;
}
$another_schedule_blocks = $another_schedule->get_blocks();
if ( empty( $another_schedule_blocks ) ) {
return $this;
}
if ( empty( $this->get_blocks() ) ) {
return $another_schedule;
}
$merged_schedule = $this->get_clone();
foreach ( $another_schedule_blocks as $block) {
$merged_schedule = $merged_schedule->add_block( $block );
}
return $merged_schedule;
}
public function cleaned() {
$last_block = null;
$reduced_blocks = array();
foreach ($this->get_blocks() as $block) {
$last_block = array_pop( $reduced_blocks );
if ( empty( $last_block ) ) {
$reduced_blocks[] = $block;
continue;
}
if ( ! $last_block->can_merge( $block ) ) {
$reduced_blocks[] = $last_block;
$reduced_blocks[] = $block;
continue;
}
$reduced_blocks[] = $last_block->merge( $block );
}
return $this->set_blocks( $reduced_blocks );
}
public function get_blocks_for_period( Period $period ) {
$overlapping_blocks = array();
foreach ($this->get_blocks() as $key => $block) {
if ( $block->get_period()->overlaps( $period ) ) {
$overlapping_blocks[] = $block;
}
}
return $overlapping_blocks;
}
public function get_block_for_date( $date ) {
$boundary_period = $this->boundaries();
if ( empty( $boundary_period ) || ! $boundary_period->contains( $date ) ) {
return false;
}
foreach ($this->get_blocks() as $key => $block) {
if ( $block->get_period()->contains( $date ) ) {
return $block;
}
}
return false;
}
public function get_free_busy_schedule( SSA_Appointment_Type_Object $appointment_type = null, $minimum_free_capacity = 1 ) {
$schedule = new SSA_Availability_Schedule();
foreach ( $this->get_blocks() as $block ) {
$block = $block->set_capacity_available( min( $block->capacity_available, $minimum_free_capacity ) );
$block = $block->set_capacity_reserved( min( $block->capacity_reserved, $minimum_free_capacity ) );
$block = $block->set_buffer_available( min( $block->buffer_available, $minimum_free_capacity ) );
$block = $block->set_buffer_reserved( min( $block->buffer_reserved, $minimum_free_capacity ) );
if ( $block->capacity_available >= $minimum_free_capacity && ( ! empty( $appointment_type ) ) ) {
if ( 'group' === $appointment_type->capacity_type ) {
if ( $block->capacity_reserved > 0 ) {
$block = $block->set_capacity_available( 0 );
}
}
}
$schedule = $schedule->pushmerge( $block );
}
return $schedule;
}
public function is_appointment_period_available( SSA_Appointment_Object $appointment, SSA_Appointment_Type_Object $appointment_type = null ) {
$appointment_buffered_period = $appointment->get_buffered_period();
$blocks = $this->get_blocks_for_period( $appointment_buffered_period );
if ( empty( $blocks ) ) {
return false;
}
if ( empty( $appointment_type ) ) {
$appointment_type = $appointment->get_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)
}
/* Check Buffered Period conflicts */
if ( $appointment_type->get_buffer_capacity_multiplier() ) {
foreach ($blocks as $block) {
if ( $block->buffer_available <= 0 ) {
return false;
}
if ( $block->capacity_available <= 0 && $block->capacity_reserved > 0 ) {
return false;
}
}
}
/* Check raw appointment period - less common case, deferred from initial loop for performance reasons */
$appointment_period = $appointment->get_appointment_period();
$appointment_period_blocks = $this->get_blocks_for_period( $appointment_period );
if ( empty( $appointment_period_blocks ) ) {
return false;
}
foreach ($blocks as $block) {
if ( ! $block->get_period()->overlaps( $appointment_period ) ) {
continue;
}
if ( $block->capacity_available <= 0 ) {
return false;
}
if ( 2 == $appointment_type->get_buffer_capacity_multiplier() ) {
if ( $block->buffer_available <= 1 ) {
return false;
}
}
}
return true;
}
/**
* Iteratively reduces the sequence to a single value using a callback.
*
* @param callable $func Accepts the carry, the current value, and
* returns an updated carry value.
*
* @param mixed|null $carry Optional initial carry value.
*
* @return mixed The carry value of the final iteration, or the initial
* value if the sequence was empty.
*/
public function reduce(callable $func, $carry = null) {
foreach ($this->intervals as $offset => $interval) {
$carry = $func($carry, $interval, $offset);
}
return $carry;
}
/* Countable */
public function count() : int {
return count( $this->blocks );
}
/* Iterator */
public function rewind() : void {
$this->position = 0;
}
#[\ReturnTypeWillChange]
public function current() {
return $this->blocks[$this->position];
}
#[\ReturnTypeWillChange]
public function key() {
return $this->position;
}
public function next() : void {
++$this->position;
}
public function valid() : bool {
return isset($this->blocks[$this->position]);
}
}