← All changes
|
includes/page-appointment-services/appointment_services__booking.php
+161
-2
11.8
→
11.9
View file →
| @@ -221,8 +221,13 @@ | ||
| 221 | 221 | |
| 222 | 222 | /** |
| 223 | 223 | * Persist an immutable Service snapshot after the core booking save succeeds. |
| 224 | 224 | * |
| 225 | + * The core save path invokes this handler before releasing its availability | |
| 226 | + * guard, and the released public hook invokes it again for compatibility. A | |
| 227 | + * request-local content key makes that duplicate call harmless while still | |
| 228 | + * allowing a later save with changed Service data in the same request. | |
| 229 | + * | |
| 225 | 230 | * @param int $booking_id Saved booking ID. |
| 226 | 231 | * @param array $create_params Normalized booking creation parameters. |
| 227 | 232 | * @param string $where_to_save_booking Booking save context supplied by core. |
| 228 | 233 | * |
| @@ -228,14 +233,32 @@ | ||
| 228 | 233 | * |
| 229 | 234 | * @return void |
| 230 | 235 | */ |
| 231 | 236 | function wpbc_appointment_services_after_booking_save( $booking_id, $create_params, $where_to_save_booking ) { |
| 237 | + static $persisted_snapshot_keys = array(); | |
| 238 | + | |
| 232 | 239 | if ( empty( $create_params['appointment_service'] ) || empty( $create_params['resource_id'] ) ) { |
| 233 | 240 | return; |
| 234 | 241 | } |
| 242 | + | |
| 243 | + $booking_id = absint( $booking_id ); | |
| 244 | + $snapshot_key = $booking_id . ':' . md5( | |
| 245 | + wp_json_encode( | |
| 246 | + array( | |
| 247 | + 'resource_id' => absint( $create_params['resource_id'] ), | |
| 248 | + 'appointment_service' => $create_params['appointment_service'], | |
| 249 | + ) | |
| 250 | + ) | |
| 251 | + ); | |
| 252 | + if ( isset( $persisted_snapshot_keys[ $snapshot_key ] ) ) { | |
| 253 | + return; | |
| 254 | + } | |
| 255 | + | |
| 235 | 256 | $saved = wpbc_appointment_services_repository()->save_appointment_snapshot( $booking_id, $create_params['resource_id'], $create_params['appointment_service'] ); |
| 236 | - if ( ! $saved ) { | |
| 237 | - do_action( 'wpbc_appointment_snapshot_save_failed', absint( $booking_id ), $create_params, $where_to_save_booking ); | |
| 257 | + if ( $saved ) { | |
| 258 | + $persisted_snapshot_keys[ $snapshot_key ] = true; | |
| 259 | + } else { | |
| 260 | + do_action( 'wpbc_appointment_snapshot_save_failed', $booking_id, $create_params, $where_to_save_booking ); | |
| 238 | 261 | } |
| 239 | 262 | } |
| 240 | 263 | |
| 241 | 264 | add_action( 'wpbc_booking_after_save', 'wpbc_appointment_services_after_booking_save', 10, 3 ); |
| @@ -856,8 +879,144 @@ | ||
| 856 | 879 | return new WP_Error( 'appointment_service_duration_invalid', __( 'The selected Service does not fit in the chosen day. Please select an earlier start time.', 'booking' ) ); |
| 857 | 880 | } |
| 858 | 881 | |
| 859 | 882 | return $end_seconds; |
| 883 | +} | |
| 884 | + | |
| 885 | +/** | |
| 886 | + * Normalize Working Time intervals into continuous scheduling ranges. | |
| 887 | + * | |
| 888 | + * Setup Wizard can define several intervals for one weekday. Gaps remain | |
| 889 | + * scheduling boundaries, while touching or overlapping legacy intervals are | |
| 890 | + * merged so one continuous range is evaluated consistently. | |
| 891 | + * | |
| 892 | + * @param array<int,array<string,int>> $working_intervals Working Time intervals. | |
| 893 | + * | |
| 894 | + * @return array<int,array{start_second:int,end_second:int}> Normalized continuous intervals. | |
| 895 | + */ | |
| 896 | +function wpbc_appointment_services_normalize_working_intervals( $working_intervals ) { | |
| 897 | + $normalized_intervals = array(); | |
| 898 | + | |
| 899 | + foreach ( (array) $working_intervals as $working_interval ) { | |
| 900 | + if ( ! is_array( $working_interval ) || ! isset( $working_interval['start_second'], $working_interval['end_second'] ) ) { | |
| 901 | + continue; | |
| 902 | + } | |
| 903 | + | |
| 904 | + $start_second = max( 0, min( DAY_IN_SECONDS, absint( $working_interval['start_second'] ) ) ); | |
| 905 | + $end_second = max( 0, min( DAY_IN_SECONDS, absint( $working_interval['end_second'] ) ) ); | |
| 906 | + if ( $start_second >= $end_second ) { | |
| 907 | + continue; | |
| 908 | + } | |
| 909 | + | |
| 910 | + $normalized_intervals[] = array( | |
| 911 | + 'start_second' => $start_second, | |
| 912 | + 'end_second' => $end_second, | |
| 913 | + ); | |
| 914 | + } | |
| 915 | + | |
| 916 | + usort( | |
| 917 | + $normalized_intervals, | |
| 918 | + static function ( $left_interval, $right_interval ) { | |
| 919 | + return $left_interval['start_second'] - $right_interval['start_second']; | |
| 920 | + } | |
| 921 | + ); | |
| 922 | + | |
| 923 | + $continuous_intervals = array(); | |
| 924 | + foreach ( $normalized_intervals as $normalized_interval ) { | |
| 925 | + $last_interval_index = count( $continuous_intervals ) - 1; | |
| 926 | + if ( $last_interval_index < 0 || $normalized_interval['start_second'] > $continuous_intervals[ $last_interval_index ]['end_second'] ) { | |
| 927 | + $continuous_intervals[] = $normalized_interval; | |
| 928 | + continue; | |
| 929 | + } | |
| 930 | + | |
| 931 | + $continuous_intervals[ $last_interval_index ]['end_second'] = max( | |
| 932 | + $continuous_intervals[ $last_interval_index ]['end_second'], | |
| 933 | + $normalized_interval['end_second'] | |
| 934 | + ); | |
| 935 | + } | |
| 936 | + | |
| 937 | + return $continuous_intervals; | |
| 938 | +} | |
| 939 | + | |
| 940 | +/** | |
| 941 | + * Check one Service interval against Working Time intervals for a single day. | |
| 942 | + * | |
| 943 | + * The complete Provider-reserved interval must fit inside one continuous | |
| 944 | + * Working Time interval. This includes the Service's before and after buffers; | |
| 945 | + * a gap between two daily intervals cannot be crossed. | |
| 946 | + * | |
| 947 | + * @param array<string,mixed> $service Effective Service values. | |
| 948 | + * @param int[] $time_seconds Exact appointment start and end seconds. | |
| 949 | + * @param array<int,array<string,int>> $working_intervals Working Time intervals for the date. | |
| 950 | + * | |
| 951 | + * @return true|WP_Error True when the reserved interval fits, otherwise an availability error. | |
| 952 | + */ | |
| 953 | +function wpbc_appointment_services_check_working_intervals( $service, $time_seconds, $working_intervals ) { | |
| 954 | + if ( empty( $service ) || ! is_array( $time_seconds ) || count( $time_seconds ) < 2 ) { | |
| 955 | + return true; | |
| 956 | + } | |
| 957 | + | |
| 958 | + $appointment_start = (int) $time_seconds[0]; | |
| 959 | + $appointment_end = (int) $time_seconds[1]; | |
| 960 | + if ( $appointment_start < 0 || $appointment_end <= $appointment_start || $appointment_end > DAY_IN_SECONDS ) { | |
| 961 | + return new WP_Error( 'appointment_service_duration_invalid', __( 'The selected Service duration is invalid. Please contact the website administrator.', 'booking' ) ); | |
| 962 | + } | |
| 963 | + | |
| 964 | + $reserved_start = $appointment_start - ( ( isset( $service['buffer_before_minutes'] ) ? absint( $service['buffer_before_minutes'] ) : 0 ) * MINUTE_IN_SECONDS ); | |
| 965 | + $reserved_end = $appointment_end + ( ( isset( $service['buffer_after_minutes'] ) ? absint( $service['buffer_after_minutes'] ) : 0 ) * MINUTE_IN_SECONDS ); | |
| 966 | + foreach ( wpbc_appointment_services_normalize_working_intervals( $working_intervals ) as $working_interval ) { | |
| 967 | + if ( $reserved_start >= $working_interval['start_second'] && $reserved_end <= $working_interval['end_second'] ) { | |
| 968 | + return true; | |
| 969 | + } | |
| 970 | + } | |
| 971 | + | |
| 972 | + return new WP_Error( | |
| 973 | + 'appointment_service_outside_working_time', | |
| 974 | + __( "This start time is unavailable because the Service duration and required buffers do not fit within the Provider's Working Time. Please choose another time.", 'booking' ) | |
| 975 | + ); | |
| 976 | +} | |
| 977 | + | |
| 978 | +/** | |
| 979 | + * Check a Service interval against the Provider's effective Working Time. | |
| 980 | + * | |
| 981 | + * Global Working Time disabled state and a Provider-specific disabled mode are | |
| 982 | + * deliberate opt-outs. Inherited and custom schedules use the canonical | |
| 983 | + * Working Time resolver, including every interval configured for a weekday. | |
| 984 | + * | |
| 985 | + * @param array<string,mixed> $service Effective Service values. | |
| 986 | + * @param int $resource_id Provider resource ID. | |
| 987 | + * @param string[] $dates Selected SQL dates. | |
| 988 | + * @param int[] $time_seconds Exact appointment start and end seconds. | |
| 989 | + * | |
| 990 | + * @return true|WP_Error True when Working Time is disabled or every date fits. | |
| 991 | + */ | |
| 992 | +function wpbc_appointment_services_check_working_time( $service, $resource_id, $dates, $time_seconds ) { | |
| 993 | + if ( empty( $service ) || ! absint( $resource_id ) || empty( $dates ) || ! function_exists( 'wpbc_working_time__get_effective_rule' ) ) { | |
| 994 | + return true; | |
| 995 | + } | |
| 996 | + | |
| 997 | + if ( false === wpbc_working_time__get_effective_rule( $resource_id ) ) { | |
| 998 | + return true; | |
| 999 | + } | |
| 1000 | + | |
| 1001 | + foreach ( (array) $dates as $date_value ) { | |
| 1002 | + $date_value = sanitize_text_field( $date_value ); | |
| 1003 | + $date_parts = array_map( 'absint', explode( '-', $date_value ) ); | |
| 1004 | + if ( $date_value !== wpbc_sanitize_date( $date_value ) || 3 !== count( $date_parts ) || ! checkdate( $date_parts[1], $date_parts[2], $date_parts[0] ) ) { | |
| 1005 | + return new WP_Error( 'appointment_dates_invalid', __( 'Select a valid appointment date and try again.', 'booking' ) ); | |
| 1006 | + } | |
| 1007 | + | |
| 1008 | + $working_time_check = wpbc_appointment_services_check_working_intervals( | |
| 1009 | + $service, | |
| 1010 | + $time_seconds, | |
| 1011 | + wpbc_working_time__get_working_intervals_for_date( $resource_id, $date_value ) | |
| 1012 | + ); | |
| 1013 | + if ( is_wp_error( $working_time_check ) ) { | |
| 1014 | + return $working_time_check; | |
| 1015 | + } | |
| 1016 | + } | |
| 1017 | + | |
| 1018 | + return true; | |
| 860 | 1019 | } |
| 861 | 1020 | |
| 862 | 1021 | /** |
| 863 | 1022 | * Convert Booking Calendar's stored boundary markers to exact interval times. |