services = new ServicesRepository(); $this->staff = new StaffRepository(); $this->locations = new LocationsRepository(); $this->availability = new AvailabilityRepository(); $this->exceptions = new AvailabilityExceptionsRepository(); $this->appointments = new AppointmentsRepository(); } /** * Generates bookable slots. * * @param array $args Slot lookup arguments. * @return array> */ public function generate_slots( array $args ): array { try { return $this->generate_slots_checked( $args ); } catch ( \RuntimeException $error ) { return array(); } } /** Authoritative callers must distinguish storage failure from no available times. */ public function generate_slots_or_throw( array $args ): array { return $this->generate_slots_checked( $args ); } private function generate_slots_checked( array $args ): array { $override = apply_filters( 'abientot_availability_override', null, 'generate', $args, null ); if ( null !== $override ) { if ( ! is_array( $override ) ) { throw new \RuntimeException( 'availability_override' ); } return $override; } $context = $this->context( $args ); if ( ! empty( $context['reason'] ) ) { return array(); } $slots = array(); $start = $this->date_from_arg( $args['date_from'] ?? '', $context['timezone'] ); $end = $this->date_from_arg( $args['date_to'] ?? '', $context['timezone'] ); // Reject supplied invalid dates instead of silently moving the request. if ( ( ! empty( $args['date_from'] ) && null === $start ) || ( ! empty( $args['date_to'] ) && null === $end ) ) { return array(); } if ( null === $start ) { $start = new DateTimeImmutable( 'today', $context['timezone'] ); } if ( null === $end || $end < $start ) { $end = $start; } $maximum_end = ( new DateTimeImmutable( 'now', DateTimeHelper::utc_timezone() ) ) ->modify( '+' . (int) $context['settings']['booking_window_days'] . ' days' ) ->setTimezone( $context['timezone'] ) ->setTime( 23, 59, 59 ); if ( $end > $maximum_end ) { $end = $maximum_end; } for ( $day = $start; $day <= $end; $day = $day->modify( '+1 day' ) ) { $slots = array_merge( $slots, $this->slots_for_day( $day, $context, ! empty( $args['include_reasons'] ) ) ); if ( ! empty( $args['first_available_day'] ) && $slots ) { break; } } /** * Filters generated availability slots. * * @param array> $slots Generated slots. * @param array $context Generation context. */ $slots = apply_filters( 'abientot_generated_slots', $slots, $context ); return is_array( $slots ) ? $slots : array(); } /** * Validates one requested local appointment start. * * @param array $args Validation arguments. * @return array */ public function validate_slot( array $args ): array { try { return $this->validate_slot_checked( $args ); } catch ( \RuntimeException $error ) { return $this->decision( false, 'storage_unavailable', array() ); } } private function validate_slot_checked( array $args ): array { $override = apply_filters( 'abientot_availability_override', null, 'validate_local', $args, null ); if ( null !== $override ) { if ( ! is_array( $override ) ) { throw new \RuntimeException( 'availability_override' ); } return $override; } $context = $this->context( $args ); if ( ! empty( $context['reason'] ) ) { return $this->decision( false, (string) $context['reason'], $context ); } $local_start = isset( $args['start_local'] ) ? (string) $args['start_local'] : ''; $local_start = str_replace( 'T', ' ', $local_start ); if ( preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $local_start ) ) { $local_start .= ':00'; } if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $local_start ) ) { return $this->decision( false, 'invalid_start', $context ); } $start = $this->local_datetime( $local_start, $context['timezone'] ); if ( null === $start ) { return $this->decision( false, 'dst_invalid', $context ); } return $this->candidate_decision( $start, $context ); } /** Trusted core callers validate a retained absolute instant without DST-fold reparsing. */ public function validate_instant( array $args, DateTimeImmutable $start ): array { try { $override = apply_filters( 'abientot_availability_override', null, 'validate_instant', $args, $start ); if ( null !== $override ) { if ( ! is_array( $override ) ) { throw new \RuntimeException( 'availability_override' ); } return $override; } $context = $this->context( $args ); if ( ! empty( $context['reason'] ) ) { return $this->decision( false, (string) $context['reason'], $context ); } return $this->candidate_decision( $start->setTimezone( $context['timezone'] ), $context ); } catch ( \RuntimeException $error ) { return $this->decision( false, 'storage_unavailable', array() ); } } private function candidate_decision( DateTimeImmutable $start, array $context ): array { $result = $this->evaluate_candidate( $start, $context ); if ( ! $result['available'] ) { return $this->decision( false, (string) $result['reason'], $context, $result ); } return $this->decision( true, 'available', $context, $result ); } /** * @param array $args Context arguments. * @return array */ /** Validate resource relationships without applying capacity or opening-hour rules. */ public function valid_booking_context( array $args ): bool { try { $override = apply_filters( 'abientot_availability_override', null, 'context', $args, null ); return null !== $override ? true === $override : empty( $this->context( $args )['reason'] ); } catch ( \RuntimeException $error ) { return false; } } private function context( array $args ): array { $settings = Settings::values(); $service_id = isset( $args['service_id'] ) ? absint( $args['service_id'] ) : 0; $staff_id = isset( $args['staff_id'] ) ? absint( $args['staff_id'] ) : 0; $location_id = isset( $args['location_id'] ) ? absint( $args['location_id'] ) : 0; $service = $this->services->find( $service_id ); $staff = $staff_id > 0 ? $this->staff->find( $staff_id ) : $this->staff->default_staff(); $location = $location_id > 0 ? $this->locations->find( $location_id ) : $this->locations->default_location(); $timezone = $this->timezone_from_records( $staff, $location, (string) $settings['business_timezone'] ); if ( ( new \Abientot\Repositories\ResourceBookingRepository() )->internal_service( $service_id ) ) { return array( 'reason' => 'retained_execution_unavailable', 'settings' => $settings, 'timezone' => $timezone ); } if ( ! $service ) { return array( 'reason' => 'service_not_found', 'settings' => $settings, 'timezone' => $timezone ); } if ( 'active' !== (string) $service['status'] ) { return array( 'reason' => 'service_inactive', 'settings' => $settings, 'timezone' => $timezone, 'service' => $service ); } if ( ! $staff || 'active' !== (string) $staff['status'] ) { return array( 'reason' => 'staff_unavailable', 'settings' => $settings, 'timezone' => $timezone, 'service' => $service ); } if ( ! $location || 'active' !== (string) $location['status'] ) { return array( 'reason' => 'location_unavailable', 'settings' => $settings, 'timezone' => $timezone, 'service' => $service, 'staff' => $staff ); } $context = array( 'service' => $service, 'staff' => $staff, 'location' => $location, 'service_id' => (int) $service['id'], 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'timezone' => $timezone, 'timezone_name' => $timezone->getName(), 'settings' => $settings, // Internal callers may preserve an existing appointment's duration. 'duration_minutes' => max( 1, (int) ( $args['duration_minutes'] ?? $service['duration_minutes'] ) ), 'buffer_before_minutes' => max( 0, (int) $service['buffer_before_minutes'] ), 'buffer_after_minutes' => max( 0, (int) $service['buffer_after_minutes'] ), 'slot_interval_minutes' => max( 5, (int) $settings['booking_slot_interval_minutes'] ), 'exclude_appointment_id' => isset( $args['exclude_appointment_id'] ) ? absint( $args['exclude_appointment_id'] ) : null, 'ignore_minimum_notice' => ! empty( $args['ignore_minimum_notice'] ), ); $context = apply_filters( 'abientot_availability_context', $context, $args ); $context['calendar_reader'] = new \Abientot\Repositories\CalendarBusyRepository(); if ( empty( $context['reason'] ) ) { $context['resource_reader'] = new \Abientot\Repositories\ResourceCapacityRepository(); $context['resource_requirements'] = $context['resource_reader']->requirements( (int) $context['service_id'], (int) $context['location_id'] ); } $quote = $args['booking_quote'] ?? null; if ( ! empty( $context['exclude_appointment_id'] ) ) { $snapshot = ( new \Abientot\Repositories\BookingSelectionsRepository() )->snapshot( (int) $context['exclude_appointment_id'] ); if ( $snapshot ) { $quote = $snapshot['quote']; } } if ( $quote !== null ) { if ( ! is_array( $quote ) || (int) ( $quote['service_id'] ?? 0 ) !== (int) $context['service_id'] || (int) ( $quote['location_id'] ?? 0 ) !== (int) $context['location_id'] || (int) ( $quote['duration_minutes'] ?? 0 ) !== (int) $context['duration_minutes'] || ! is_array( $quote['resources'] ?? null ) || count( $quote['resources'] ) > 200 ) { throw new \DomainException( 'booking_quote_context' ); } foreach ( array( 'buffer_before_minutes', 'buffer_after_minutes' )as$key ) { if ( ! isset( $quote[ $key ] ) || ! is_int( $quote[ $key ] ) || $quote[ $key ] < 0 || $quote[ $key ] > 1440 ) { throw new \DomainException( 'booking_quote_context' ); } $context[ $key ] = $quote[ $key ]; } $context['resource_requirements'] = array(); foreach ( $quote['resources']as$id => $units ) { if ( ! ctype_digit( (string) $id ) || (int) $id < 1 || ! is_int( $units ) || $units < 1 ) { throw new \DomainException( 'booking_quote_resources' ); } $context['resource_requirements'][] = array( 'resource_id' => (int) $id, 'units' => $units ); } } return $context; } /** * @param DateTimeImmutable $day Local day. * @param array $context Generation context. * @return array> */ private function slots_for_day( DateTimeImmutable $day, array $context, bool $include_reasons ): array { $date = $day->format( 'Y-m-d' ); $windows = $this->available_windows_for_day( $day, $context ); if ( empty( $windows ) ) { return $include_reasons ? array( $this->unavailable_day( $date, 'no_working_hours', $context ) ) : array(); } $slots = array(); $step = (int) $context['slot_interval_minutes']; $blocking_bookings = $this->blocking_bookings_for_windows( $windows, $context ); foreach ( $windows as $window ) { $cursor = $this->align_to_interval( max( $window['start'], $day->setTime( 0, 0, 0 ) ), $step ); while ( $cursor < $window['end'] && $cursor < $day->setTime( 0, 0, 0 )->modify( '+1 day' ) ) { $result = $this->evaluate_candidate( $cursor, $context, $windows, $blocking_bookings ); if ( $result['available'] ) { $slots[] = $this->slot_payload( $result, $context ); } elseif ( $include_reasons ) { $slots[] = $this->unavailable_slot( $cursor, (string) $result['reason'], $context ); } $cursor = $cursor->modify( '+' . $step . ' minutes' ); } } return $slots; } /** * @param array $context Context. * @param array>|null $windows Optional precomputed windows. * @return array */ private function evaluate_candidate( DateTimeImmutable $start, array $context, ?array $windows = null, ?array $blocking_bookings = null ): array { $instant = $start->setTimezone( DateTimeHelper::utc_timezone() ); $end = $instant->modify( '+' . (int) $context['duration_minutes'] . ' minutes' )->setTimezone( $context['timezone'] ); $buffer_start = $instant->modify( '-' . (int) $context['buffer_before_minutes'] . ' minutes' )->setTimezone( $context['timezone'] ); $buffer_end = $end->setTimezone( DateTimeHelper::utc_timezone() )->modify( '+' . (int) $context['buffer_after_minutes'] . ' minutes' )->setTimezone( $context['timezone'] ); $windows = $windows ?: $this->available_windows_for_day( $start, $context ); if ( ! $this->interval_fits( $buffer_start, $buffer_end, $windows ) ) { return array( 'available' => false, 'reason' => 'outside_working_hours' ); } $now_utc = new DateTimeImmutable( 'now', DateTimeHelper::utc_timezone() ); $minimum_start = $now_utc->modify( '+' . (int) $context['settings']['booking_min_notice_hours'] . ' hours' ); $maximum_start = $now_utc->modify( '+' . (int) $context['settings']['booking_window_days'] . ' days' ); $start_utc = $start->setTimezone( DateTimeHelper::utc_timezone() ); if ( empty( $context['ignore_minimum_notice'] ) && $start_utc < $minimum_start ) { return array( 'available' => false, 'reason' => 'minimum_notice' ); } if ( $start_utc > $maximum_start ) { return array( 'available' => false, 'reason' => 'outside_booking_window' ); } $buffer_start_utc = $buffer_start->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ); $buffer_end_utc = $buffer_end->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ); if ( ! $context['calendar_reader']->available( (int) $context['staff']['id'], $buffer_start_utc, $buffer_end_utc ) ) { return array( 'available' => false, 'reason' => 'external_calendar_unavailable' ); } $bookings = null === $blocking_bookings ? $this->appointments->find_blocking_for_staff_between( (int) $context['staff_id'], $buffer_start_utc, $buffer_end_utc, $context['exclude_appointment_id'] ?? null ) : $this->conflicting_bookings( $blocking_bookings, $buffer_start_utc, $buffer_end_utc ); if ( ! empty( $bookings ) ) { return array( 'available' => false, 'reason' => 'booking_conflict', 'conflicts' => $bookings ); } $resource_reader = $context['resource_reader']; $requirements = $context['resource_requirements']; if ( $requirements ) { if ( ! class_exists( 'Abientot\\Premium\\Resources\\Service', false ) ) { return array( 'available' => false, 'reason' => 'resource_execution_unavailable' ); } $allocations = array(); foreach ( $requirements as $requirement ) { $allocations[] = array( 'resource_id' => (int) $requirement['resource_id'], 'units' => (int) $requirement['units'], 'start_utc' => $buffer_start_utc, 'end_utc' => $buffer_end_utc ); } $exclude = ! empty( $context['exclude_appointment_id'] ) ? array( 'owner_type' => 'appointment', 'owner_id' => (int) $context['exclude_appointment_id'] ) : null; if ( ! $resource_reader->available( $allocations, $exclude ) ) { return array( 'available' => false, 'reason' => 'resource_unavailable' ); } } $result = array( 'available' => true, 'reason' => 'available', 'start' => $start, 'end' => $end, 'buffer_start' => $buffer_start, 'buffer_end' => $buffer_end, ); /** * Filters the final availability decision for a single slot. * * @param array $result Slot decision. * @param array $context Availability context. */ $result = apply_filters( 'abientot_is_slot_available', $result, $context ); return is_array( $result ) ? $result : array( 'available' => false, 'reason' => 'filtered_unavailable' ); } /** * Loads blocking bookings once for the generated day instead of querying per candidate slot. * * @param array> $windows Availability windows. * @param array $context Context. * @return array> */ private function blocking_bookings_for_windows( array $windows, array $context ): array { if ( empty( $windows ) ) { return array(); } $start = $windows[0]['start']; $end = $windows[0]['end']; foreach ( $windows as $window ) { if ( $window['start'] < $start ) { $start = $window['start']; } if ( $window['end'] > $end ) { $end = $window['end']; } } return $this->appointments->find_blocking_for_staff_between( (int) $context['staff_id'], $start->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ), $end->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ), $context['exclude_appointment_id'] ?? null ); } /** * @param array> $bookings Preloaded blocking bookings. * @return array> */ private function conflicting_bookings( array $bookings, string $start_utc, string $end_utc ): array { return array_values( array_filter( $bookings, static function ( array $booking ) use ( $start_utc, $end_utc ): bool { return (string) ( $booking['blocked_start_utc'] ?? $booking['start_utc'] ) < $end_utc && (string) ( $booking['blocked_end_utc'] ?? $booking['end_utc'] ) > $start_utc; } ) ); } /** * @param array $context Context. * @return array> */ private function available_windows_for_day( DateTimeImmutable $day, array $context ): array { $windows = array(); $blocks = array(); $midnight = $day->setTime( 0, 0, 0 ); // Include both incoming spill and tomorrow's continuation for duration/buffers. for ( $offset = -1; $offset <= 1; ++$offset ) { $anchor = $midnight->modify( sprintf( '%+d days', $offset ) ); $date = $anchor->format( 'Y-m-d' ); $rules = $this->availability->rules_for_staff_weekday_date( (int) $context['staff_id'], (int) $anchor->format( 'w' ), $date, (int) $context['location_id'], (int) $context['service_id'] ); foreach ( $rules as $rule ) { $interval = $this->interval_from_local_times( $date, (string) $rule['start_time'], (string) $rule['end_time'], $context['timezone'] ); if ( ! $interval ) { continue; } if ( 'working' === $rule['rule_type'] ) { $windows[] = $interval; } elseif ( 'break' === $rule['rule_type'] ) { // A morning break inside an overnight anchor belongs to its following date. foreach ( $rules as $working ) { if ( 'working' === $working['rule_type'] && $working['end_time'] < $working['start_time'] && $rule['start_time'] < $working['start_time'] && $rule['end_time'] <= $working['end_time'] ) { $interval = $this->interval_from_local_times( $anchor->modify( '+1 day' )->format( 'Y-m-d' ), (string) $rule['start_time'], (string) $rule['end_time'], $context['timezone'] ); break; } } if ( $interval ) { $blocks[] = $interval; } } } foreach ( $this->exceptions->for_staff_date( (int) $context['staff_id'], $date, (int) $context['location_id'], (int) $context['service_id'] ) as $exception ) { $type = (string) $exception['exception_type']; if ( 'closed' === $type && empty( $exception['start_time'] ) ) { $blocks[] = array( 'start' => $anchor, 'end' => $anchor->modify( '+1 day' ) ); continue; } $interval = $this->interval_from_local_times( $date, (string) $exception['start_time'], (string) $exception['end_time'], $context['timezone'] ); if ( ! $interval ) { continue; } if ( 'open' === $type ) { $windows[] = $interval; } elseif ( in_array( $type, array( 'closed', 'break' ), true ) ) { $blocks[] = $interval; } } } $windows = ScheduleIntervals::merge( $windows ); $location_schedule = apply_filters( 'abientot_location_weekly_schedule', get_option( 'abientot_location_weekly_schedule', null ), $context['location'], $context ); if ( is_array( $location_schedule ) ) { $windows = ScheduleIntervals::intersect( $windows, $this->location_windows_for_day( $day, $context, $location_schedule ) ); } foreach ( $blocks as $block ) { $windows = ScheduleIntervals::subtract( $windows, $block ); } return ScheduleIntervals::merge( $windows ); } /** Location-local weekly hours intersect staff-local days by absolute instants. */ private function location_windows_for_day( DateTimeImmutable $day, array $context, array $schedule ): array { try { $zone = new DateTimeZone( (string) ( $context['location']['timezone'] ?: $context['settings']['business_timezone'] ) ); } catch ( Exception $error ) { return array(); } $first = $day->setTime( 0, 0 )->modify( '-1 day' )->setTimezone( $zone )->setTime( 0, 0 )->modify( '-1 day' ); $last = $day->setTime( 0, 0 )->modify( '+3 days' )->setTimezone( $zone )->setTime( 0, 0 ); $windows = array(); try { for ( $anchor = $first; $anchor <= $last; $anchor = $anchor->modify( '+1 day' ) ) { $windows = array_merge( $windows, ScheduleIntervals::intervals( $schedule[ (int) $anchor->format( 'w' ) ] ?? array(), $anchor ) ); } } catch ( \InvalidArgumentException $error ) { return array(); } return array_map( static fn( array $window ): array => array( 'start' => $window['start']->setTimezone( $context['timezone'] ), 'end' => $window['end']->setTimezone( $context['timezone'] ) ), ScheduleIntervals::merge( $windows ) ); } /** * @param array> $left First interval set. * @param array> $right Second interval set. * @return array> */ private function intersect_intervals( array $left, array $right ): array { $result = array(); foreach ( $left as $left_interval ) { foreach ( $right as $right_interval ) { $start = $left_interval['start'] > $right_interval['start'] ? $left_interval['start'] : $right_interval['start']; $end = $left_interval['end'] < $right_interval['end'] ? $left_interval['end'] : $right_interval['end']; if ( $start < $end ) { $result[] = array( 'start' => $start, 'end' => $end ); } } } return $this->merge_intervals( $result ); } /** * @return array|null */ private function interval_from_local_times( string $date, string $start_time, string $end_time, DateTimeZone $timezone ): ?array { if ( '' === $start_time || '' === $end_time ) { return null; } $start_time = substr( $start_time, 0, 8 ); $end_time = substr( $end_time, 0, 8 ); $start = $this->local_datetime( $date . ' ' . $start_time, $timezone ); $end_date = $end_time < $start_time ? ( new DateTimeImmutable( $date, $timezone ) )->modify( '+1 day' )->format( 'Y-m-d' ) : $date; $end = $this->local_datetime( $end_date . ' ' . $end_time, $timezone ); if ( null === $start || null === $end || $end <= $start ) { return null; } return array( 'start' => $start, 'end' => $end ); } private function local_datetime( string $local_datetime, DateTimeZone $timezone ): ?DateTimeImmutable { try { $date = new DateTimeImmutable( $local_datetime, $timezone ); } catch ( Exception $exception ) { return null; } return $date->format( 'Y-m-d H:i:s' ) === $local_datetime ? $date : null; } /** * @param array> $intervals Intervals. * @return array> */ private function merge_intervals( array $intervals ): array { usort( $intervals, static function ( array $a, array $b ): int { return $a['start'] <=> $b['start']; } ); $merged = array(); foreach ( $intervals as $interval ) { if ( empty( $merged ) ) { $merged[] = $interval; continue; } $key = array_key_last( $merged ); $current = $merged[ $key ]; if ( $current['end'] < $interval['start'] ) { $merged[] = $interval; continue; } if ( $interval['end'] > $merged[ $key ]['end'] ) { $merged[ $key ]['end'] = $interval['end']; } } return $merged; } /** * @param array> $windows Availability windows. * @return array> */ private function subtract_interval( array $windows, array $block ): array { $result = array(); foreach ( $windows as $window ) { if ( $block['end'] <= $window['start'] || $block['start'] >= $window['end'] ) { $result[] = $window; continue; } if ( $block['start'] > $window['start'] ) { $result[] = array( 'start' => $window['start'], 'end' => $block['start'] < $window['end'] ? $block['start'] : $window['end'], ); } if ( $block['end'] < $window['end'] ) { $result[] = array( 'start' => $block['end'] > $window['start'] ? $block['end'] : $window['start'], 'end' => $window['end'], ); } } return $result; } /** * @param array> $windows Availability windows. */ private function interval_fits( DateTimeImmutable $start, DateTimeImmutable $end, array $windows ): bool { foreach ( $windows as $window ) { if ( $start >= $window['start'] && $end <= $window['end'] ) { return true; } } return false; } private function align_to_interval( DateTimeImmutable $time, int $interval_minutes ): DateTimeImmutable { $minutes = ( (int) $time->format( 'H' ) * 60 ) + (int) $time->format( 'i' ); $aligned = (int) ceil( $minutes / $interval_minutes ) * $interval_minutes; return $time->setTime( intdiv( $aligned, 60 ), $aligned % 60, 0 ); } /** * @param array|null $staff Staff row. * @param array|null $location Location row. */ private function timezone_from_records( ?array $staff, ?array $location, string $fallback ): DateTimeZone { $candidates = array( is_array( $staff ) ? (string) ( $staff['timezone'] ?? '' ) : '', is_array( $location ) ? (string) ( $location['timezone'] ?? '' ) : '', $fallback, ); foreach ( $candidates as $candidate ) { if ( '' === $candidate ) { continue; } try { return new DateTimeZone( $candidate ); } catch ( Exception $exception ) { continue; } } return DateTimeHelper::business_timezone(); } private function date_from_arg( $value, DateTimeZone $timezone ): ?DateTimeImmutable { if ( ! is_string( $value ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) { return null; } try { $date = new DateTimeImmutable( $value . ' 00:00:00', $timezone ); return $date->format( 'Y-m-d' ) === $value ? $date : null; } catch ( Exception $exception ) { return null; } } /** * @param array $result Evaluated slot result. * @param array $context Context. * @return array */ private function slot_payload( array $result, array $context ): array { return array( 'available' => true, 'date' => $result['start']->format( 'Y-m-d' ), 'start_local' => $result['start']->format( 'Y-m-d H:i:s' ), 'end_local' => $result['end']->format( 'Y-m-d H:i:s' ), 'start_utc' => $result['start']->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ), 'end_utc' => $result['end']->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ), 'timezone' => (string) $context['timezone_name'], 'staff_id' => (int) $context['staff_id'], 'location_id' => (int) $context['location_id'], 'service_id' => (int) $context['service_id'], 'duration_minutes' => (int) $context['duration_minutes'], 'reason' => 'available', ); } /** * @param array $context Context. * @return array */ private function unavailable_day( string $date, string $reason, array $context ): array { return array( 'available' => false, 'date' => $date, 'reason' => $reason, 'staff_id' => (int) $context['staff_id'], 'location_id' => (int) $context['location_id'], 'service_id' => (int) $context['service_id'], ); } /** * @param array $context Context. * @return array */ private function unavailable_slot( DateTimeImmutable $start, string $reason, array $context ): array { return array( 'available' => false, 'date' => $start->format( 'Y-m-d' ), 'start_local' => $start->format( 'Y-m-d H:i:s' ), 'reason' => $reason, 'staff_id' => (int) $context['staff_id'], 'location_id' => (int) $context['location_id'], 'service_id' => (int) $context['service_id'], ); } /** * @param array $context Context. * @param array $details Extra decision details. * @return array */ private function decision( bool $available, string $reason, array $context, array $details = array() ): array { return array_merge( $details, array( 'available' => $available, 'reason' => $reason, 'staff_id' => isset( $context['staff_id'] ) ? (int) $context['staff_id'] : 0, 'location_id' => isset( $context['location_id'] ) ? (int) $context['location_id'] : 0, 'service_id' => isset( $context['service_id'] ) ? (int) $context['service_id'] : 0, 'timezone' => isset( $context['timezone_name'] ) ? (string) $context['timezone_name'] : '', ) ); } }