ready !== null ) { return $this->ready; } $read = new ResourceCapacityRepository(); $version = get_option( 'abientot_resource_booking_schema_version', '' ); if ( ! in_array( $version, array( '', '1', '2' ), true ) ) { throw new \RuntimeException( 'retained_store_incomplete' ); } $definitions = array( 'resource_categories' => array( 'id' => 'bigint unsigned', 'name' => 'varchar(190)', 'location_id' => 'bigint unsigned', 'internal_service_id' => 'bigint unsigned', 'estimated_minutes' => 'int unsigned', 'pinned' => 'int unsigned', 'status' => 'varchar(20)', 'schedule_source' => 'varchar(20)', 'schedule_json' => 'longtext' ), 'resource_profiles' => array( 'resource_id' => 'bigint unsigned', 'category_id' => 'bigint unsigned', 'guest_capacity' => 'int unsigned', 'exclusive' => 'int unsigned', 'estimated_minutes' => 'int unsigned', 'schedule_source' => 'varchar(20)', 'schedule_json' => 'longtext' ), 'resource_bookings' => array( 'appointment_id' => 'bigint unsigned', 'category_id' => 'bigint unsigned', 'resource_id' => 'bigint unsigned', 'party_size' => 'int unsigned', 'exclusive' => 'int unsigned', 'estimated_minutes' => 'int unsigned', 'start_utc' => 'datetime', 'end_utc' => 'datetime', 'released_at' => 'datetime' ) ); if ( $version === '2' ) { foreach ( array( 'resource_categories', 'resource_profiles' )as$table ) { $definitions[ $table ] += array( 'price_cents' => 'bigint unsigned', 'price_mode' => 'varchar(20)' ); } $definitions['resource_bookings'] += array( 'unit_price_cents' => 'bigint unsigned', 'price_mode' => 'varchar(20)', 'total_price_cents' => 'bigint unsigned', 'currency' => 'char(3)', 'category_label' => 'varchar(190)', 'resource_label' => 'varchar(190)', 'legacy_staff_id' => 'bigint unsigned' ); } return $this->ready = OptionalStoreSchema::present( $read, 'abientot_resource_booking_schema_version', $version === '' ? '1' : $version, $definitions, array( 'resource_categories' => array( array( 'id' ), array( 'internal_service_id' ) ), 'resource_profiles' => array( array( 'resource_id' ) ), 'resource_bookings' => array( array( 'appointment_id' ) ) ) ); } public function internal_service( int $service ): bool { global $wpdb; if ( $service < 1 || ! $this->present() ) { return false; } $read = new ResourceCapacityRepository(); return (bool) Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id FROM %i WHERE internal_service_id=%d LIMIT 1', $read->table( 'resource_categories' ), $service ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); } public function snapshot( int $appointment ): ?array { global $wpdb; if ( $appointment < 1 || ! $this->present() ) { return null; } $read = new ResourceCapacityRepository(); $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE appointment_id=%d', $read->table( 'resource_bookings' ), $appointment ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( ! $rows ) { return null; } $row = $rows[0]; if ( (int) $row['party_size'] < 1 || (int) $row['resource_id'] < 1 || (int) $row['category_id'] < 1 || (int) $row['estimated_minutes'] < 1 || ! in_array( (string) $row['exclusive'], array( '0', '1' ), true ) || $row['start_utc'] >= $row['end_utc'] || ( $row['released_at'] !== null && ( $row['released_at'] < $row['start_utc'] || $row['released_at'] > $row['end_utc'] ) ) ) { throw new \RuntimeException( 'retained_resource_booking_invalid' ); } if ( get_option( 'abientot_resource_booking_schema_version' ) === '2' ) { $unit = (int) $row['unit_price_cents']; $count = $row['price_mode'] === 'guest' ? (int) $row['party_size'] : 1; if ( $row['legacy_staff_id'] === null || $unit < 0 || ! in_array( $row['price_mode'], array( 'resource', 'guest' ), true ) || $unit > intdiv( 2147483647, $count ) || $unit * $count !== (int) $row['total_price_cents'] || ! preg_match( '/^[A-Z]{3}$/D', $row['currency'] ) ) { throw new \RuntimeException( 'retained_resource_price_invalid' ); } } return $row; } public function has_unassigned(): bool { if ( ! $this->present() ) { return false; } global $wpdb; return (bool) Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( "SELECT a.id FROM {$wpdb->prefix}abientot_appointments a JOIN {$wpdb->prefix}abientot_resource_bookings b ON b.appointment_id=a.id WHERE a.staff_id=0 LIMIT 1", ARRAY_A ), 'retained_capacity_storage_unavailable' ); } /** Bounded decoration of canonical readers in either edition. Historical labels never follow configuration edits. */ public function decorate_rows( array $rows ): array { if ( ! $rows ) { return $rows; } $facts = array(); if ( $this->present() ) { global $wpdb; $ids = array_unique( array_map( 'intval', array_column( $rows, 'id' ) ) ); foreach ( array_chunk( $ids, 200 )as$chunk ) { foreach ( Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}abientot_resource_bookings WHERE appointment_id IN (" . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')', $chunk ), ARRAY_A ), 'retained_capacity_storage_unavailable' )as$fact ) { $facts[ (int) $fact['appointment_id'] ] = $fact; } } } foreach ( $rows as&$row ) { $row['booking_kind'] = 'service'; $row['resource_category'] = ''; $row['resource_label'] = ''; $row['party_size'] = 1; $fact = $facts[ (int) $row['id'] ] ?? null; if ( $fact ) { $row['booking_kind'] = 'resource'; $row['resource_category'] = (string) ( $fact['category_label'] ?? '' ); $row['resource_label'] = (string) ( $fact['resource_label'] ?? '' ); $row['resource_category_id'] = (int) $fact['category_id']; $row['resource_id'] = (int) $fact['resource_id']; $row['party_size'] = (int) $fact['party_size']; $row['service_name'] = sprintf( /* translators: %s: resource category name. */ __( 'Resource: %s', 'abientot' ), $row['resource_category'] ); } if ( (int) $row['staff_id'] === 0 ) { $row['staff_name'] = __( 'No staff assigned', 'abientot' ); } } unset( $row ); return $rows; } /** Preserve snapshots and release evidence for safe status/payment/note edits. */ public function guard( array $after, ?array $before ): bool { global $wpdb; $snapshot = $this->snapshot( (int) ( $after['id'] ?? 0 ) ); if ( ! $snapshot ) { if ( $this->internal_service( (int) $after['service_id'] ) ) { throw new \DomainException( 'retained_execution_unavailable' ); } return false; } if ( get_option( 'abientot_resource_booking_schema_version' ) === '2' && ( (int) $after['price_cents'] !== (int) $snapshot['total_price_cents'] || $after['currency'] !== $snapshot['currency'] ) ) { throw new \DomainException( 'retained_resource_price_changed' ); } if ( $before ) { $compare = $after; $compare['staff_id'] = $before['staff_id']; if ( BookingConstraints::structural( $compare, $before ) ) { throw new \DomainException( 'retained_execution_unavailable' ); } foreach ( array( 'price_cents', 'currency' )as$key ) { if ( (string) $after[ $key ] !== (string) $before[ $key ] ) { throw new \DomainException( 'retained_resource_booking_changed' ); } } if ( (int) $after['staff_id'] !== (int) $before['staff_id'] && ( get_option( 'abientot_resource_booking_schema_version' ) !== '2' || ! class_exists( 'Abientot\\Premium\\Resources\\AdminBooking', false ) ) ) { throw new \DomainException( 'retained_execution_unavailable' ); } } if ( class_exists( 'Abientot\\Premium\\Resources\\AdminBooking', false ) && BookingConstraints::blocking( $after ) && ( ! $before || (int) $after['staff_id'] !== (int) $before['staff_id'] || ! BookingConstraints::blocking( $before ) ) ) { \Abientot\Premium\Resources\AdminBooking::validate_assignment( $after ); } if ( $snapshot['start_utc'] !== $after['start_utc'] || $snapshot['end_utc'] !== $after['end_utc'] || (int) $snapshot['estimated_minutes'] !== (int) $after['duration_minutes'] ) { throw new \DomainException( 'retained_resource_booking_changed' ); } $read = new ResourceCapacityRepository(); $category = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT internal_service_id,location_id FROM %i WHERE id=%d', $read->table( 'resource_categories' ), (int) $snapshot['category_id'] ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( ! $category || (int) $category[0]['internal_service_id'] !== (int) $after['service_id'] || (int) $category[0]['location_id'] !== (int) $after['location_id'] ) { throw new \DomainException( 'retained_resource_booking_changed' ); } $allocations = $read->for_owner( 'appointment', (int) $after['id'] ); if ( count( $allocations ) !== 1 || (int) $allocations[0]['resource_id'] !== (int) $snapshot['resource_id'] || (int) $allocations[0]['units'] !== ( (int) $snapshot['exclusive'] === 1 ? 1 : (int) $snapshot['party_size'] ) || $allocations[0]['start_utc'] !== $snapshot['start_utc'] || $allocations[0]['end_utc'] !== $snapshot['end_utc'] ) { throw new \DomainException( 'retained_resource_allocation_changed' ); } if ( ! $before && BookingConstraints::blocking( $after ) && ! $read->available( $allocations, array( 'owner_type' => 'appointment', 'owner_id' => (int) $after['id'] ) ) ) { throw new \DomainException( 'resource_unavailable' ); } if ( $before && ! BookingConstraints::blocking( $before ) && BookingConstraints::blocking( $after ) ) { $allocations = $read->for_owner( 'appointment', (int) $after['id'] ); if ( $snapshot['released_at'] === null ) { if ( ! $read->available( $allocations, array( 'owner_type' => 'appointment', 'owner_id' => (int) $after['id'] ) ) ) { throw new \DomainException( 'resource_unavailable' ); } $profile = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT p.*,r.location_id,r.status FROM %i p JOIN %i r ON r.id=p.resource_id WHERE p.resource_id=%d', $read->table( 'resource_profiles' ), $read->table( 'resources' ), (int) $snapshot['resource_id'] ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( ! $profile || (int) $profile[0]['category_id'] !== (int) $snapshot['category_id'] || (int) $profile[0]['exclusive'] !== (int) $snapshot['exclusive'] || (int) $profile[0]['guest_capacity'] < (int) $snapshot['party_size'] || (int) $profile[0]['location_id'] !== (int) $after['location_id'] || $profile[0]['status'] !== 'active' ) { throw new \DomainException( 'resource_unavailable' ); } if ( ! class_exists( 'Abientot\\Premium\\Resources\\BookingService', false ) ) { throw new \DomainException( 'retained_execution_unavailable' ); } $choice = ( new \Abientot\Premium\Resources\BookingService() )->candidate( (int) $snapshot['category_id'], (int) $snapshot['party_size'], $snapshot['start_utc'], (int) $after['id'], (int) $snapshot['resource_id'], true ); if ( ! $choice || $choice['end_utc'] !== $snapshot['end_utc'] || (int) $choice['estimated_minutes'] !== (int) $snapshot['estimated_minutes'] ) { throw new \DomainException( 'resource_unavailable' ); } } } return true; } public function released_intervals( array $rows ): array { if ( ! $this->present() ) { return $rows; } $out = array();foreach ( $rows as$row ) { if ( $row['owner_type'] === 'appointment' ) { $snapshot = $this->snapshot( (int) $row['owner_id'] ); if ( $snapshot ) { if ( (int) $snapshot['resource_id'] !== (int) $row['resource_id'] ) { throw new \RuntimeException( 'retained_resource_allocation' ); } if ( $snapshot['released_at'] !== null ) { $row['end_utc'] = min( $row['end_utc'], $snapshot['released_at'] ); } } } if ( $row['end_utc'] > $row['start_utc'] ) { $out[] = $row; } } return $out; } }