repository = $repository; $this->references = $references; } public function register(): void { add_action( self::HOOK, array( $this, 'run_batch' ) ); } /** * Whether Omnibus data exists. Cached for 12 hours, because it runs on admin screens. */ public function has_data(): bool { $cached = get_transient( self::TRANSIENT ); if ( false !== $cached ) { return 'yes' === $cached; } global $wpdb; $keys = implode( ',', array_fill( 0, count( self::META_KEYS ), '%s' ) ); // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders -- IN () lists of %s placeholders, prepared below. $found = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} WHERE post_type = %s LIMIT 1", self::POST_TYPE ) ) || (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key IN ( {$keys} ) LIMIT 1", self::META_KEYS ) ); // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders set_transient( self::TRANSIENT, $found ? 'yes' : 'no', 12 * HOUR_IN_SECONDS ); return $found; } /** * @return array{status:string,last_id:int,products:int,intervals:int,started:int,finished:int} */ public static function state(): array { $state = get_option( self::OPTION, array() ); return wp_parse_args( is_array( $state ) ? $state : array(), array( 'status' => self::STATUS_IDLE, 'last_id' => 0, 'products' => 0, 'intervals' => 0, 'started' => 0, 'finished' => 0, ) ); } private static function save_state( array $state ): void { update_option( self::OPTION, $state, false ); } public static function set_status( string $status ): void { $state = self::state(); $state['status'] = $status; self::save_state( $state ); } /** * Starts (or restarts) the import. * * @param bool $background Run via Action Scheduler (false when WP-CLI drives the batches). */ public function start( bool $background = true ): void { self::save_state( array( 'status' => self::STATUS_RUNNING, 'last_id' => 0, 'products' => 0, 'intervals' => 0, 'started' => time(), 'finished' => 0, ) ); if ( $background && function_exists( 'as_enqueue_async_action' ) ) { as_enqueue_async_action( self::HOOK, array(), Reconciler::GROUP ); } } /** * Imports the next batch of products. * * @param bool $chain Schedule the next batch (false when driven by WP-CLI). * @return int Products looked at in this batch; 0 means the import is complete. */ public function run_batch( $chain = true ): int { $state = self::state(); if ( self::STATUS_RUNNING !== $state['status'] ) { return 0; } $ids = $this->next_product_ids( (int) $state['last_id'], self::BATCH_SIZE ); foreach ( $ids as $id ) { $imported = $this->import_product( $id ); if ( $imported > 0 ) { ++$state['products']; $state['intervals'] += $imported; } $state['last_id'] = $id; } if ( count( $ids ) < self::BATCH_SIZE ) { $state['status'] = self::STATUS_DONE; $state['finished'] = time(); } elseif ( $chain && function_exists( 'as_enqueue_async_action' ) ) { as_enqueue_async_action( self::HOOK, array(), Reconciler::GROUP ); } self::save_state( $state ); return count( $ids ); } /** * @return int Number of intervals written. */ public function import_product( int $product_id ): int { $product = wc_get_product( $product_id ); if ( ! $product || ! PriceResolver::is_trackable( $product ) || $this->repository->has_source( $product_id, self::SOURCE ) ) { return 0; } $currency = PriceResolver::currency(); $to_gross = static function ( float $net ) use ( $product ) { return PriceResolver::to_gross( $product, $net ); }; $snapshots = array(); foreach ( $this->load_records( $product_id ) as $record ) { $snapshot = OmnibusSnapshot::from_record( $record['data'], $record['fallback_ts'], $record['price_is_regular'], $to_gross ); if ( null !== $snapshot && ( null === $snapshot->currency || $currency === $snapshot->currency ) ) { $snapshots[] = $snapshot; } } $end = $this->repository->earliest_start( $product_id, $currency ); $intervals = Timeline::build( $snapshots, $end ); foreach ( $intervals as $interval ) { $snapshot = $interval['snapshot']; $change = new Change(); $change->product_id = $product_id; $change->parent_id = $product->get_parent_id(); $change->currency = $currency; $change->price = $snapshot->price; $change->regular_price = $snapshot->regular_price; $change->reduced = $snapshot->reduced; $change->valid_from = Repository::to_datetime( $interval['from'] ); $change->source = self::SOURCE; $change->user_id = $snapshot->user_id; $this->repository->insert_interval( $change, null === $interval['to'] ? null : Repository::to_datetime( $interval['to'] ) ); } if ( $intervals ) { $this->references->refresh( $product ); } return count( $intervals ); } /** * Product IDs with Omnibus data, in ID order, after $after_id. * * @return int[] */ private function next_product_ids( int $after_id, int $limit ): array { global $wpdb; $keys = implode( ',', array_fill( 0, count( self::META_KEYS ), '%s' ) ); $sql = "SELECT id FROM ( SELECT DISTINCT post_parent AS id FROM {$wpdb->posts} WHERE post_type = %s AND post_parent > %d UNION SELECT DISTINCT post_id AS id FROM {$wpdb->postmeta} WHERE meta_key IN ( {$keys} ) AND post_id > %d ) ids ORDER BY id ASC LIMIT %d"; $args = array_merge( array( self::POST_TYPE, $after_id ), self::META_KEYS, array( $after_id, $limit ) ); // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders -- IN () lists of %s placeholders, prepared below. return array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( $sql, $args ) ) ); // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders } /** * All Omnibus records of a product in both formats. * * @return array,fallback_ts:int|null,price_is_regular:bool}> */ private function load_records( int $product_id ): array { global $wpdb; $records = array(); // 3.x: one post per record, the values in its post meta. $fields = implode( ',', array_fill( 0, count( self::LOG_FIELDS ), '%s' ) ); // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders -- IN () lists of %s placeholders, prepared below. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_date_gmt, m.meta_key, m.meta_value FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key IN ( {$fields} ) WHERE p.post_type = %s AND p.post_parent = %d AND p.post_status = 'publish'", array_merge( self::LOG_FIELDS, array( self::POST_TYPE, $product_id ) ) ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders $posts = array(); foreach ( (array) $rows as $row ) { $id = (int) $row['ID']; if ( ! isset( $posts[ $id ] ) ) { $posts[ $id ] = array( 'data' => array(), 'fallback_ts' => '0000-00-00 00:00:00' === $row['post_date_gmt'] ? null : Repository::to_timestamp( $row['post_date_gmt'] ), 'price_is_regular' => false, ); } if ( null !== $row['meta_key'] ) { $posts[ $id ]['data'][ $row['meta_key'] ] = self::safe_unserialize( $row['meta_value'] ); } } $records = array_values( $posts ); // Installs that never migrated to 3.x: serialized arrays in the product's post meta. $keys = implode( ',', array_fill( 0, count( self::META_KEYS ), '%s' ) ); // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders -- IN () lists of %s placeholders, prepared below. $meta = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key IN ( {$keys} )", array_merge( array( $product_id ), self::META_KEYS ) ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders foreach ( (array) $meta as $row ) { $data = self::safe_unserialize( $row['meta_value'] ); if ( is_array( $data ) ) { $records[] = array( 'data' => $data, 'fallback_ts' => null, 'price_is_regular' => '_iwo_price_last_change' === $row['meta_key'], ); } } return $records; } /** * Unserializes data written by another plugin without instantiating objects. * * @param mixed $value * @return mixed */ private static function safe_unserialize( $value ) { if ( is_string( $value ) && is_serialized( $value ) ) { return @unserialize( trim( $value ), array( 'allowed_classes' => false ) ); // phpcs:ignore } return $value; } }