schema_ready !== null ) { return $this->schema_ready; } return $this->schema_ready = OptionalStoreSchema::present( $this, 'abientot_resources_schema_version', '1', array( 'resources' => array( 'id' => 'bigint unsigned', 'label' => 'varchar(190)', 'location_id' => 'bigint unsigned', 'capacity' => 'int unsigned', 'status' => 'varchar(20)' ), 'service_resources' => array( 'service_id' => 'bigint unsigned', 'resource_id' => 'bigint unsigned', 'units' => 'int unsigned' ), 'resource_allocations' => array( 'owner_type' => 'varchar(20)', 'owner_id' => 'bigint unsigned', 'resource_id' => 'bigint unsigned', 'units' => 'int unsigned', 'start_utc' => 'datetime', 'end_utc' => 'datetime' ) ), array( 'resources' => array( array( 'id' ) ), 'service_resources' => array( array( 'service_id', 'resource_id' ) ), 'resource_allocations' => array( array( 'owner_type', 'owner_id', 'resource_id' ) ) ) ); } public function table( string $suffix ): string { global $wpdb; return $wpdb->prefix . 'abientot_' . $suffix; } public function exists( string $suffix ): bool { global $wpdb; $table = $this->table( $suffix ); $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); return ! empty( $rows ); } public function requirements( int $service, int $location ): array { global $wpdb; if ( ! $this->present() ) { return array(); } $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT q.resource_id,q.units,r.capacity,r.status,r.location_id FROM {$wpdb->prefix}abientot_service_resources q LEFT JOIN {$wpdb->prefix}abientot_resources r ON r.id=q.resource_id WHERE q.service_id=%d ORDER BY q.resource_id LIMIT 201", $service ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( count( $rows ) > 200 ) { throw new \RuntimeException( 'retained_capacity_limit' ); } foreach ( $rows as$r ) { if ( (int) $r['units'] < 1 || (int) $r['capacity'] < 1 || $r['status'] !== 'active' || (int) $r['location_id'] !== $location || (int) $r['units'] > (int) $r['capacity'] ) { throw new \RuntimeException( 'retained_resource_unavailable' ); } } return $rows; } public function for_owner( string $type, int $id ): array { global $wpdb; if ( ! in_array( $type, array( 'appointment', 'session' ), true ) || $id < 1 ) { throw new \RuntimeException( 'retained_capacity_owner' ); } if ( ! $this->present() ) { return array(); } $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT resource_id,units,start_utc,end_utc FROM {$wpdb->prefix}abientot_resource_allocations WHERE owner_type=%s AND owner_id=%d ORDER BY resource_id LIMIT 201", $type, $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( count( $rows ) > 200 ) { throw new \RuntimeException( 'retained_capacity_limit' ); } return $rows; } /** Only canonical blocking owners contribute; missing appointment status stays blocking. */ public function occupancy( int $resource, string $after ): array { global $wpdb; if ( ! $this->present() ) { return array(); } $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT r.owner_type,r.owner_id,r.resource_id,r.units,r.start_utc,r.end_utc FROM {$wpdb->prefix}abientot_resource_allocations r LEFT JOIN {$wpdb->prefix}abientot_appointments a ON a.id=r.owner_id LEFT JOIN {$wpdb->prefix}abientot_booking_statuses s ON s.id=a.status_id WHERE r.owner_type='appointment' AND r.resource_id=%d AND r.end_utc>%s AND COALESCE(s.blocks_availability,1)=1 ORDER BY r.start_utc,r.end_utc LIMIT 10001", $resource, $after ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( $this->exists( 'group_sessions' ) ) { $rows = array_merge( $rows, Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT r.owner_type,r.owner_id,r.resource_id,r.units,r.start_utc,r.end_utc FROM {$wpdb->prefix}abientot_resource_allocations r LEFT JOIN {$wpdb->prefix}abientot_group_sessions g ON g.id=r.owner_id WHERE r.owner_type='session' AND r.resource_id=%d AND r.end_utc>%s AND COALESCE(g.status,'scheduled')<>'cancelled' ORDER BY r.start_utc,r.end_utc LIMIT 10001", $resource, $after ), ARRAY_A ), 'retained_capacity_storage_unavailable' ) ); } if ( count( $rows ) > 10000 ) { throw new \RuntimeException( 'retained_capacity_limit' ); } foreach ( $rows as$r ) { if ( (int) $r['units'] < 1 || $r['start_utc'] >= $r['end_utc'] ) { throw new \RuntimeException( 'retained_capacity_invalid' ); } } $this->retained_reader = $this->retained_reader ?? new ResourceBookingRepository(); return $this->retained_reader->released_intervals( $rows ); } /** Peak concurrency, never the sum of all overlaps. Endpoints use half-open intervals. */ public static function peak( array $intervals ): int { $points = array(); foreach ( $intervals as$r ) { if ( $r['start_utc'] >= $r['end_utc'] || (int) $r['units'] < 1 ) { throw new \RuntimeException( 'retained_capacity_invalid' ); } $points[] = array( $r['start_utc'], (int) $r['units'] ); $points[] = array( $r['end_utc'], - (int) $r['units'] ); } usort( $points, static fn( $a, $b )=>strcmp( $a[0], $b[0] ) ?: ( $a[1] <=> $b[1] ) ); $used = 0; $peak = 0; foreach ( $points as$point ) { $used += $point[1]; $peak = max( $peak, $used ); } return $peak; } public function available( array $allocations, ?array $excludeOwner = null ): bool { global $wpdb; $seen = array(); foreach ( $allocations as$candidate ) { $resource = (int) $candidate['resource_id']; if ( $resource < 1 || isset( $seen[ $resource ] ) || $candidate['start_utc'] >= $candidate['end_utc'] || (int) $candidate['units'] < 1 ) { throw new \RuntimeException( 'retained_capacity_invalid' ); } $seen[ $resource ] = true; $r = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT capacity,status FROM {$wpdb->prefix}abientot_resources WHERE id=%d", $resource ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( empty( $r ) || $r[0]['status'] !== 'active' ) { return false; } $overlap = array( $candidate ); foreach ( $this->occupancy( $resource, $candidate['start_utc'] ) as$existing ) { if ( \Abientot\Support\BookingConstraints::exclude_resource_owner( $existing ) ) { continue; } if ( $excludeOwner && $existing['owner_type'] === ( $excludeOwner['owner_type'] ?? '' ) && (int) $existing['owner_id'] === (int) ( $excludeOwner['owner_id'] ?? 0 ) ) { continue; } if ( $existing['start_utc'] >= $candidate['end_utc'] || $existing['end_utc'] <= $candidate['start_utc'] ) { continue; } $existing['start_utc'] = max( $existing['start_utc'], $candidate['start_utc'] ); $existing['end_utc'] = min( $existing['end_utc'], $candidate['end_utc'] ); $overlap[] = $existing; } if ( self::peak( $overlap ) > (int) $r[0]['capacity'] ) { return false; } } return true; } }