12 ) { throw new \RuntimeException( 'booking_batch_owner' ); } $rows = array(); $repo = new \Abientot\Repositories\AppointmentsRepository(); foreach ( $beforeRows as$before ) { $id = (int) ( $before['id'] ?? 0 ); if ( $id < 1 || isset( $rows[ $id ] ) ) { throw new \DomainException( 'booking_batch_scope' ); } $actual = $repo->find( $id ); if ( ! $actual ) { throw new \DomainException( 'booking_batch_scope' ); } foreach ( $actual as$key => $value ) { if ( ! array_key_exists( $key, $before ) || ( $value === null ) !== ( $before[ $key ] === null ) || (string) $value !== (string) $before[ $key ] ) { throw new \DomainException( 'booking_batch_stale' ); } } $rows[ $id ] = false; } self::$batch = array( 'rows' => $rows, 'owner' => $owner ); try { $out = $operation(); if ( ! $owner() || in_array( false, self::$batch['rows'], true ) ) { throw new \RuntimeException( 'booking_batch_incomplete' ); } return $out; } finally { self::$batch = null; } } public static function batch_active(): bool { return self::$batch !== null; } public static function excluded_ids(): array { return self::$batch === null ? array() : array_keys( array_filter( self::$batch['rows'], static fn( $done )=> ! $done ) ); } public static function exclude_resource_owner( array $owner ): bool { return ( $owner['owner_type'] ?? '' ) === 'appointment' && in_array( (int) ( $owner['owner_id'] ?? 0 ), self::excluded_ids(), true ); } private static function batch_applied( int $id ): void { if ( self::$batch === null ) { return; } if ( ! array_key_exists( $id, self::$batch['rows'] ) || self::$batch['rows'][ $id ] || ! call_user_func( self::$batch['owner'] ) ) { throw new \RuntimeException( 'booking_batch_scope' ); } self::$batch['rows'][ $id ] = true; } public static function structural( array $after, ?array $before ): bool { if ( ! $before ) { return true; } foreach ( array( 'service_id', 'staff_id', 'location_id', 'start_utc', 'end_utc', 'duration_minutes' )as$key ) { if ( (string) ( $after[ $key ] ?? '' ) !== (string) ( $before[ $key ] ?? '' ) ) { return true; } } return false; } public static function blocking( array $booking ): bool { global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT blocks_availability FROM {$wpdb->prefix}abientot_booking_statuses WHERE id=%d", (int) $booking['status_id'] ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); return ! $rows || ! empty( $rows[0]['blocks_availability'] ); } /** Called after canonical write, before COMMIT. Any failure rolls back the complete operation. */ public static function apply( array $after, ?array $before = null, ?callable $owner = null ): void { global $wpdb; $repo = new ResourceCapacityRepository(); $id = (int) ( $after['id'] ?? 0 ); if ( $id < 1 || ! BookingLock::is_held() ) { throw new \RuntimeException( 'booking_constraint_owner' ); } Currency::validate_booking( $after, $before ); if ( class_exists( 'Abientot\\Premium\\Instructions\\Service', false ) ) { if ( ! $owner || $owner() !== true ) { throw new \RuntimeException( 'booking_instruction_owner' ); } $instructions = new \Abientot\Premium\Instructions\Service(); if ( $before === null ) { $instructions->snapshot( $after, strpos( Locale::locale(), 'fr' ) === 0 ? 'fr_FR' : 'en_US', $owner ); } else { $instructions->changed( $after, $owner ); $instructions->completed( $after, $owner ); } } ( new \Abientot\Repositories\CreditRepository() )->guard( $after, $before ); $finance = new \Abientot\Repositories\PaymentPlanRepository(); $finance->guard( $after, $before ); ( new \Abientot\Repositories\BookingSelectionsRepository() )->guard( $after, $before ); ( new \Abientot\Repositories\BookingInstructionsRepository() )->guard( $after, $before ); ( new \Abientot\Repositories\PartyAttendeesRepository() )->guard( $after, $before ); ( new \Abientot\Repositories\CustomerSeriesRepository() )->individual_edit( $after, $before ); if ( $finance->present() && ( $before === null || $finance->plan( $id ) ) && class_exists( 'Abientot\\Premium\\Payments\\Finance', false ) ) { \Abientot\Premium\Payments\Finance::snapshot_booking( $after, $before ); } if ( ( new \Abientot\Repositories\ResourceBookingRepository() )->guard( $after, $before ) ) { self::batch_applied( $id ); return; } if ( (int) $after['staff_id'] <= 0 ) { throw new \DomainException( 'booking_staff_required' ); } if ( ( new \Abientot\Repositories\GroupBookingRepository() )->present() ) { if ( get_option( 'abientot_groups_schema_version' ) === '2' ) { $members = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT m.seat_count,m.unit_price_cents,m.session_id,g.status,g.capacity,g.service_id,g.staff_id,g.location_id,g.start_utc,g.end_utc,g.duration_minutes,g.price_cents,g.currency FROM {$wpdb->prefix}abientot_group_memberships m LEFT JOIN {$wpdb->prefix}abientot_group_sessions g ON g.id=m.session_id WHERE m.appointment_id=%d", $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); } else { $members = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT 1 AS seat_count,g.price_cents AS unit_price_cents,m.session_id,g.status,g.capacity,g.service_id,g.staff_id,g.location_id,g.start_utc,g.end_utc,g.duration_minutes,g.price_cents,g.currency FROM {$wpdb->prefix}abientot_group_memberships m LEFT JOIN {$wpdb->prefix}abientot_group_sessions g ON g.id=m.session_id WHERE m.appointment_id=%d", $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); } if ( $members ) { $member = $members[0]; $seats = (int) $member['seat_count']; $unit = (int) $member['unit_price_cents']; if ( $seats < 1 || $seats > 20 || $unit < 0 || $unit > intdiv( 2147483647, $seats ) || (int) $after['price_cents'] !== $unit * $seats ) { throw new \DomainException( 'group_booking_requires_session' ); } foreach ( array( 'service_id', 'staff_id', 'location_id', 'start_utc', 'end_utc', 'duration_minutes', 'currency' )as$key ) { if ( (string) $after[ $key ] !== (string) $member[ $key ] ) { throw new \DomainException( 'group_booking_requires_session' ); } } if ( self::blocking( $after ) ) { if ( $member['status'] !== 'scheduled' ) { throw new \DomainException( 'group_session_unavailable' ); } if ( get_option( 'abientot_groups_schema_version' ) === '2' ) { $count = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT SUM(m.seat_count) AS occupied FROM {$wpdb->prefix}abientot_group_memberships m INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=m.appointment_id LEFT JOIN {$wpdb->prefix}abientot_booking_statuses s ON s.id=a.status_id WHERE m.session_id=%d AND COALESCE(s.blocks_availability,1)=1", (int) $member['session_id'] ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); } else { $count = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT SUM(1) AS occupied FROM {$wpdb->prefix}abientot_group_memberships m INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=m.appointment_id LEFT JOIN {$wpdb->prefix}abientot_booking_statuses s ON s.id=a.status_id WHERE m.session_id=%d AND COALESCE(s.blocks_availability,1)=1", (int) $member['session_id'] ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); } if ( (int) $count[0]['occupied'] > (int) $member['capacity'] ) { throw new \DomainException( 'group_session_full' ); } $duplicate = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT COUNT(*) AS occupied FROM {$wpdb->prefix}abientot_group_memberships m INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=m.appointment_id LEFT JOIN {$wpdb->prefix}abientot_booking_statuses s ON s.id=a.status_id WHERE m.session_id=%d AND a.customer_id=%d AND COALESCE(s.blocks_availability,1)=1", (int) $member['session_id'], (int) $after['customer_id'] ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( (int) $duplicate[0]['occupied'] > 1 ) { throw new \DomainException( 'group_customer_duplicate' ); } if ( $before && ! self::blocking( $before ) && ! class_exists( 'Abientot\\Premium\\Groups\\Service', false ) ) { throw new \DomainException( 'group_booking_requires_session' ); } } self::batch_applied( $id ); return; // Session, never each participant, owns physical resource allocation. } } if ( $before === null && ( $after['source'] ?? '' ) === 'import' ) { $end = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', (string) $after['end_utc'], new \DateTimeZone( 'UTC' ) ); if ( ! $end || $end->format( 'Y-m-d H:i:s' ) !== $after['end_utc'] ) { throw new \DomainException( 'import_historical_interval' ); } if ( $end->getTimestamp() <= time() ) { self::batch_applied( $id ); return; } } $retained = $repo->for_owner( 'appointment', $id ); $structural = self::structural( $after, $before ); if ( ! $structural ) { if ( $before && ! self::blocking( $before ) && self::blocking( $after ) && $retained && ! $repo->available( $retained, array( 'owner_type' => 'appointment', 'owner_id' => $id ) ) ) { throw new \DomainException( 'resource_unavailable' ); } self::batch_applied( $id ); return; } $requirements = $repo->requirements( (int) $after['service_id'], (int) $after['location_id'] ); if ( ( $requirements || $retained ) && ! class_exists( 'Abientot\\Premium\\Resources\\Service', false ) ) { throw new \DomainException( 'resource_execution_unavailable' ); } if ( class_exists( 'Abientot\\Premium\\Resources\\Service', false ) ) { $service = new \Abientot\Premium\Resources\Service(); $allocations = $service->allocations_for_booking( $after, $before ); if ( self::blocking( $after ) && ! $repo->available( $allocations, array( 'owner_type' => 'appointment', 'owner_id' => $id ) ) ) { throw new \DomainException( 'resource_unavailable' ); } $service->apply_booking( $after, $before ); } self::batch_applied( $id ); } }