Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
68.55% |
109 / 159 |
|
21.74% |
5 / 23 |
CRAP | |
0.00% |
0 / 1 |
| Lookup_Data_Backfill | |
68.99% |
109 / 158 |
|
21.74% |
5 / 23 |
306.60 | |
0.00% |
0 / 1 |
| register | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| is_synced | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| is_initial_sync_finished | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| on_sync_processed_actions | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
9.24 | |||
| on_sync_status_response | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| on_dashboard_screen | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| maybe_schedule | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
10 | |||
| start_backfill | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
13 | |||
| forget_lost_run | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
9.01 | |||
| schedule | |
50.00% |
1 / 2 |
|
0.00% |
0 / 1 |
2.50 | |||
| wpcom_has_order_history | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
6.04 | |||
| find_oldest_order_product | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| fetch_wpcom_products | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
56 | |||
| has_orders | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| has_lookup_rows | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| table_has_rows | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| is_jetpack_connected | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| is_sync_allowed | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| is_full_sync_running | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| get_full_sync_status | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| get_product_ids | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| start_full_sync | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| do_full_sync | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * One-time full sync that backfills the WooCommerce Analytics lookup data. |
| 4 | * |
| 5 | * @package automattic/jetpack-woocommerce-stats |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\WooCommerceStats; |
| 9 | |
| 10 | use Automattic\Jetpack\Connection\Client; |
| 11 | use Automattic\Jetpack\Connection\Manager as Connection_Manager; |
| 12 | use Automattic\Jetpack\PremiumAnalytics\Analytics; |
| 13 | use Automattic\Jetpack\PremiumAnalytics\Sync\Sync_Status_Tracker; |
| 14 | use Automattic\Jetpack\Sync\Actions; |
| 15 | use Automattic\Jetpack\Sync\Modules; |
| 16 | use DateTimeImmutable; |
| 17 | use Exception; |
| 18 | use Jetpack_Options; |
| 19 | |
| 20 | defined( 'ABSPATH' ) || exit; |
| 21 | |
| 22 | /** |
| 23 | * Starts one full sync of the order lookup data and the product catalog on stores whose orders synced before |
| 24 | * Premium Analytics owned the woocommerce_analytics module, when the standalone plugin stripped the lookup data. |
| 25 | * |
| 26 | * Incremental sync and checksums never resend those orders, so WordPress.com keeps their order |
| 27 | * stats without the product, tax and coupon lookup rows. |
| 28 | */ |
| 29 | class Lookup_Data_Backfill { |
| 30 | |
| 31 | /** |
| 32 | * Timestamp of the first full woocommerce_analytics sync that finished under Premium Analytics, which, |
| 33 | * unlike the standalone plugin's, sends the lookup data. The backfill also sets it when nothing is missing. |
| 34 | */ |
| 35 | const SYNCED_OPTION = 'jetpack_premium_analytics_lookup_data_synced'; |
| 36 | |
| 37 | /** |
| 38 | * Option holding the backfill state: `scheduled_at` and `started_at` timestamps. Delete it to run the backfill again. |
| 39 | */ |
| 40 | const STATE_OPTION = 'jetpack_premium_analytics_lookup_backfill'; |
| 41 | |
| 42 | /** |
| 43 | * Action Scheduler hook that starts the full sync. |
| 44 | */ |
| 45 | const START_HOOK = 'jetpack_premium_analytics_lookup_backfill'; |
| 46 | |
| 47 | /** |
| 48 | * Full sync context, so the backfill can be told apart in the sync status. |
| 49 | */ |
| 50 | const FULL_SYNC_CONTEXT = 'premium_analytics_lookup_backfill'; |
| 51 | |
| 52 | /** |
| 53 | * Seconds to wait before retrying while the full sync cannot start or WordPress.com does not answer. |
| 54 | */ |
| 55 | const RETRY_DELAY = 10 * MINUTE_IN_SECONDS; |
| 56 | |
| 57 | /** |
| 58 | * Seconds after the first attempt to keep retrying. |
| 59 | */ |
| 60 | const RETRY_WINDOW = DAY_IN_SECONDS; |
| 61 | |
| 62 | /** |
| 63 | * Set by the standalone WooCommerce Analytics plugin when its first full sync finishes; it survives deactivation. |
| 64 | */ |
| 65 | const PLUGIN_FULL_SYNC_OPTION = 'woocommerce_analytics_initial_full_sync_finished'; |
| 66 | |
| 67 | /** |
| 68 | * Most product and variation IDs to list in the full sync config; above it, all posts are resent. |
| 69 | */ |
| 70 | const MAX_PRODUCT_IDS = 5000; |
| 71 | |
| 72 | /** |
| 73 | * Lookup tables the standalone plugin left out of its sync. |
| 74 | */ |
| 75 | const LOOKUP_TABLES = array( 'wc_order_product_lookup', 'wc_order_tax_lookup', 'wc_order_coupon_lookup' ); |
| 76 | |
| 77 | /** |
| 78 | * Register the hooks. |
| 79 | * |
| 80 | * @return void |
| 81 | */ |
| 82 | public function register(): void { |
| 83 | // Priority 11: Sync_Status_Tracker sets the milestone from the stored full sync status at 10. |
| 84 | add_filter( 'rest_post_dispatch', array( $this, 'on_sync_status_response' ), 11, 3 ); |
| 85 | add_action( 'current_screen', array( $this, 'on_dashboard_screen' ) ); |
| 86 | add_action( self::START_HOOK, array( $this, 'start_backfill' ) ); |
| 87 | add_action( 'jetpack_sync_processed_actions', array( $this, 'on_sync_processed_actions' ) ); |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * Whether a full woocommerce_analytics sync finished under Premium Analytics, or the backfill found nothing missing. |
| 92 | * |
| 93 | * @return bool |
| 94 | */ |
| 95 | private static function is_synced(): bool { |
| 96 | return (int) get_option( self::SYNCED_OPTION, 0 ) > 0; |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Whether the first analytics full sync has finished. |
| 101 | * |
| 102 | * @return bool |
| 103 | */ |
| 104 | private static function is_initial_sync_finished(): bool { |
| 105 | // An older copy of the dashboard package has no accessor; its milestone is this option. |
| 106 | if ( ! method_exists( Sync_Status_Tracker::class, 'has_initial_sync_finished' ) ) { |
| 107 | return (int) get_option( Sync_Status_Tracker::INITIAL_ANALYTICS_SYNC_OPTION, 0 ) > 0; |
| 108 | } |
| 109 | |
| 110 | return Sync_Status_Tracker::has_initial_sync_finished(); |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Mark the lookup data synced when a full sync of the whole woocommerce_analytics module ends. |
| 115 | * |
| 116 | * Runs even when the milestone exists, since on these stores it may come from the standalone plugin's sync. |
| 117 | * |
| 118 | * @param array $actions Processed sync actions. |
| 119 | * @return void |
| 120 | */ |
| 121 | public function on_sync_processed_actions( array $actions ): void { |
| 122 | if ( self::is_synced() ) { |
| 123 | return; |
| 124 | } |
| 125 | |
| 126 | $end_action = null; |
| 127 | foreach ( $actions as $action ) { |
| 128 | if ( isset( $action[0] ) && 'jetpack_full_sync_end' === $action[0] ) { |
| 129 | $end_action = $action; |
| 130 | break; |
| 131 | } |
| 132 | } |
| 133 | // The end action's own timestamp is the "finished at" value; the stored status is written after it fires. |
| 134 | $finished_at = isset( $end_action[3] ) ? (int) $end_action[3] : 0; |
| 135 | if ( $finished_at <= 0 ) { |
| 136 | return; |
| 137 | } |
| 138 | |
| 139 | $config = (array) ( $this->get_full_sync_status()['config'] ?? array() ); |
| 140 | // An array config is a date range, a partial sync that leaves the other orders without lookup data. |
| 141 | if ( empty( $config['woocommerce_analytics'] ) || is_array( $config['woocommerce_analytics'] ) ) { |
| 142 | return; |
| 143 | } |
| 144 | |
| 145 | update_option( self::SYNCED_OPTION, $finished_at ); |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Check for a backfill when the dashboard polls GET /jetpack/v4/sync/status, which it does until the milestone is set. |
| 150 | * |
| 151 | * @param mixed $response Result to send to the client. Usually a WP_REST_Response. |
| 152 | * @param mixed $server The REST server instance (unused). |
| 153 | * @param mixed $request The request used to generate the response. |
| 154 | * @return mixed The unchanged response. |
| 155 | */ |
| 156 | public function on_sync_status_response( $response, $server, $request ) { |
| 157 | if ( $request instanceof \WP_REST_Request |
| 158 | && Sync_Status_Tracker::SYNC_STATUS_ROUTE === $request->get_route() |
| 159 | && $response instanceof \WP_REST_Response |
| 160 | && ! $response->is_error() ) { |
| 161 | $this->maybe_schedule(); |
| 162 | } |
| 163 | |
| 164 | return $response; |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Check for a backfill when the dashboard loads, which covers stores whose milestone was set before and no longer poll. |
| 169 | * |
| 170 | * @return void |
| 171 | */ |
| 172 | public function on_dashboard_screen(): void { |
| 173 | if ( Analytics::is_dashboard_request() ) { |
| 174 | $this->maybe_schedule(); |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * Schedule the backfill when an earlier analytics full sync left orders on WordPress.com without lookup data. |
| 180 | * |
| 181 | * @return void |
| 182 | */ |
| 183 | public function maybe_schedule(): void { |
| 184 | if ( self::is_synced() ) { |
| 185 | return; |
| 186 | } |
| 187 | |
| 188 | $state = get_option( self::STATE_OPTION ); |
| 189 | if ( false !== $state && ! $this->forget_lost_run( $state ) ) { |
| 190 | return; |
| 191 | } |
| 192 | |
| 193 | // Wait for the first analytics full sync; if it runs under Premium Analytics, its end marks the data synced. |
| 194 | if ( ! self::is_initial_sync_finished() ) { |
| 195 | return; |
| 196 | } |
| 197 | |
| 198 | // Without the standalone plugin's full sync, Premium Analytics sent every order's lookup data itself. |
| 199 | if ( (int) get_option( self::PLUGIN_FULL_SYNC_OPTION, 0 ) <= 0 || ! $this->has_orders() || ! $this->has_lookup_rows() ) { |
| 200 | update_option( self::SYNCED_OPTION, time() ); |
| 201 | return; |
| 202 | } |
| 203 | |
| 204 | if ( ! function_exists( 'as_schedule_single_action' ) || as_has_scheduled_action( self::START_HOOK ) ) { |
| 205 | return; |
| 206 | } |
| 207 | |
| 208 | update_option( |
| 209 | self::STATE_OPTION, |
| 210 | array( |
| 211 | 'scheduled_at' => time(), |
| 212 | 'started_at' => 0, |
| 213 | ) |
| 214 | ); |
| 215 | $this->schedule( time(), 0 ); |
| 216 | } |
| 217 | |
| 218 | /** |
| 219 | * Start the full sync unless WordPress.com already holds the order history, or retry later while |
| 220 | * Jetpack cannot sync, another full sync runs, or WordPress.com does not answer. |
| 221 | * |
| 222 | * @param mixed $first_attempt Time of the first attempt; 0 for this one. |
| 223 | * @return void |
| 224 | */ |
| 225 | public function start_backfill( $first_attempt = 0 ): void { |
| 226 | $first_attempt = absint( $first_attempt ); |
| 227 | if ( 0 === $first_attempt ) { |
| 228 | $first_attempt = time(); |
| 229 | } |
| 230 | $can_retry = time() < $first_attempt + self::RETRY_WINDOW; |
| 231 | |
| 232 | $state = get_option( self::STATE_OPTION ); |
| 233 | $state = is_array( $state ) ? $state : array( 'scheduled_at' => 0 ); |
| 234 | if ( self::is_synced() || ! empty( $state['started_at'] ) ) { |
| 235 | return; |
| 236 | } |
| 237 | |
| 238 | if ( $this->is_jetpack_connected() && $this->is_sync_allowed() && ! $this->is_full_sync_running() ) { |
| 239 | $has_history = $this->wpcom_has_order_history(); |
| 240 | if ( true === $has_history ) { |
| 241 | update_option( self::SYNCED_OPTION, time() ); |
| 242 | return; |
| 243 | } |
| 244 | |
| 245 | // Without an answer, start anyway once the retries run out: a needless full sync beats missing history. |
| 246 | if ( false === $has_history || ! $can_retry ) { |
| 247 | // Claim the run before starting it, so a concurrent action for it does nothing. |
| 248 | update_option( self::STATE_OPTION, array_merge( $state, array( 'started_at' => time() ) ) ); |
| 249 | if ( $this->start_full_sync() ) { |
| 250 | return; |
| 251 | } |
| 252 | update_option( self::STATE_OPTION, $state ); |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | if ( $can_retry ) { |
| 257 | $this->schedule( time() + self::RETRY_DELAY, $first_attempt ); |
| 258 | return; |
| 259 | } |
| 260 | |
| 261 | // Give up on this run; the next dashboard check schedules a new one. |
| 262 | delete_option( self::STATE_OPTION ); |
| 263 | } |
| 264 | |
| 265 | /** |
| 266 | * Forget a run whose action is gone or whose full sync another one replaced, or settle one whose end went unnoticed. |
| 267 | * |
| 268 | * @param array|mixed $state Stored backfill state. |
| 269 | * @return bool Whether the run was forgotten, so a new one can be scheduled. |
| 270 | */ |
| 271 | private function forget_lost_run( $state ): bool { |
| 272 | if ( ! is_array( $state ) ) { |
| 273 | return false; |
| 274 | } |
| 275 | |
| 276 | if ( empty( $state['started_at'] ) ) { |
| 277 | // A canceled or failed action leaves nothing to start the run. |
| 278 | if ( ! function_exists( 'as_has_scheduled_action' ) || as_has_scheduled_action( self::START_HOOK ) ) { |
| 279 | return false; |
| 280 | } |
| 281 | delete_option( self::STATE_OPTION ); |
| 282 | return true; |
| 283 | } |
| 284 | |
| 285 | $status = $this->get_full_sync_status(); |
| 286 | if ( self::FULL_SYNC_CONTEXT === ( $status['context'] ?? null ) ) { |
| 287 | if ( empty( $status['finished'] ) ) { |
| 288 | return false; |
| 289 | } |
| 290 | if ( true === ( $status['progress']['woocommerce_analytics']['finished'] ?? false ) ) { |
| 291 | update_option( self::SYNCED_OPTION, (int) $status['finished'] ); |
| 292 | return false; |
| 293 | } |
| 294 | } elseif ( $this->is_full_sync_running() ) { |
| 295 | // The replacing full sync may include the module; its end marks the data synced if so. |
| 296 | return false; |
| 297 | } |
| 298 | |
| 299 | delete_option( self::STATE_OPTION ); |
| 300 | return true; |
| 301 | } |
| 302 | |
| 303 | /** |
| 304 | * Schedule the start action. When Action Scheduler cannot store it, forget the run so a later check schedules it again. |
| 305 | * |
| 306 | * @param int $timestamp When the action runs. |
| 307 | * @param int $first_attempt Time of the first attempt; 0 when this schedules the first one. |
| 308 | * @return void |
| 309 | */ |
| 310 | private function schedule( int $timestamp, int $first_attempt ): void { |
| 311 | if ( ! as_schedule_single_action( $timestamp, self::START_HOOK, array( $first_attempt ) ) ) { |
| 312 | delete_option( self::STATE_OPTION ); |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * Whether WordPress.com reports the product of the oldest completed order, with its type from the product catalog. |
| 318 | * |
| 319 | * Only lookup rows put the product in the report, and only a synced catalog gives it a type. Orders synced after |
| 320 | * Premium Analytics took over carry lookup rows, so only an old order tells whether the history arrived. |
| 321 | * |
| 322 | * @return bool|null Null when WordPress.com did not answer. |
| 323 | */ |
| 324 | protected function wpcom_has_order_history(): ?bool { |
| 325 | $order = $this->find_oldest_order_product(); |
| 326 | if ( null === $order ) { |
| 327 | return false; |
| 328 | } |
| 329 | |
| 330 | try { |
| 331 | $created = new DateTimeImmutable( $order['date_created'], wp_timezone() ); |
| 332 | } catch ( Exception $e ) { |
| 333 | return false; |
| 334 | } |
| 335 | |
| 336 | // A day either side absorbs any time zone difference between the stored date and the report. |
| 337 | $products = $this->fetch_wpcom_products( |
| 338 | array( |
| 339 | 'from' => $created->modify( '-1 day' )->setTime( 0, 0 )->format( 'Y-m-d\TH:i:sP' ), |
| 340 | 'to' => $created->modify( '+1 day' )->setTime( 23, 59, 59 )->format( 'Y-m-d\TH:i:sP' ), |
| 341 | 'date_type' => 'created', |
| 342 | 'limit' => 100, |
| 343 | ) |
| 344 | ); |
| 345 | if ( null === $products ) { |
| 346 | return null; |
| 347 | } |
| 348 | |
| 349 | foreach ( $products as $product ) { |
| 350 | if ( (int) ( $product['product_id'] ?? 0 ) === (int) $order['product_id'] ) { |
| 351 | return ! empty( $product['product_type'] ); |
| 352 | } |
| 353 | } |
| 354 | return false; |
| 355 | } |
| 356 | |
| 357 | /** |
| 358 | * The product and creation date of the oldest completed order with product lookup rows. |
| 359 | * |
| 360 | * Completed orders are never in WordPress.com's excluded report statuses. |
| 361 | * |
| 362 | * @return array{product_id: int|string, date_created: string}|null |
| 363 | */ |
| 364 | protected function find_oldest_order_product(): ?array { |
| 365 | global $wpdb; |
| 366 | |
| 367 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Runs once per store, from the backfill action. |
| 368 | $row = $wpdb->get_row( |
| 369 | "SELECT lookup.product_id, stats.date_created |
| 370 | FROM {$wpdb->prefix}wc_order_stats stats |
| 371 | INNER JOIN {$wpdb->prefix}wc_order_product_lookup lookup ON lookup.order_id = stats.order_id |
| 372 | WHERE stats.status = 'wc-completed' AND stats.parent_id = 0 |
| 373 | ORDER BY stats.date_created ASC |
| 374 | LIMIT 1", |
| 375 | ARRAY_A |
| 376 | ); |
| 377 | |
| 378 | return is_array( $row ) ? $row : null; |
| 379 | } |
| 380 | |
| 381 | /** |
| 382 | * Fetch rows of the WordPress.com products report, signed as the blog. |
| 383 | * |
| 384 | * @param array $args Report query arguments. |
| 385 | * @return array|null Product rows, or null when the request failed. |
| 386 | */ |
| 387 | protected function fetch_wpcom_products( array $args ): ?array { |
| 388 | // add_query_arg() doesn't encode values, and the date offsets' `+` would arrive as a space. |
| 389 | $path = add_query_arg( urlencode_deep( $args ), sprintf( '/sites/%d/analytics/reports/products', (int) Jetpack_Options::get_option( 'id' ) ) ); |
| 390 | |
| 391 | try { |
| 392 | $response = Client::wpcom_json_api_request_as_blog( $path, '2', array( 'timeout' => 15 ), null, 'wpcom' ); |
| 393 | } catch ( Exception $e ) { |
| 394 | return null; |
| 395 | } |
| 396 | |
| 397 | if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { |
| 398 | return null; |
| 399 | } |
| 400 | |
| 401 | $body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 402 | return is_array( $body ) && isset( $body['data'] ) && is_array( $body['data'] ) ? $body['data'] : null; |
| 403 | } |
| 404 | |
| 405 | /** |
| 406 | * Whether WooCommerce Analytics holds any order stats. |
| 407 | * |
| 408 | * @return bool |
| 409 | */ |
| 410 | protected function has_orders(): bool { |
| 411 | return self::table_has_rows( 'wc_order_stats' ); |
| 412 | } |
| 413 | |
| 414 | /** |
| 415 | * Whether any WooCommerce Analytics lookup table has rows. |
| 416 | * |
| 417 | * @return bool |
| 418 | */ |
| 419 | protected function has_lookup_rows(): bool { |
| 420 | foreach ( self::LOOKUP_TABLES as $table ) { |
| 421 | if ( self::table_has_rows( $table ) ) { |
| 422 | return true; |
| 423 | } |
| 424 | } |
| 425 | return false; |
| 426 | } |
| 427 | |
| 428 | /** |
| 429 | * Whether a WooCommerce table has at least one row; a missing table has none. |
| 430 | * |
| 431 | * @param string $table Table name without the prefix. |
| 432 | * @return bool |
| 433 | */ |
| 434 | private static function table_has_rows( string $table ): bool { |
| 435 | global $wpdb; |
| 436 | |
| 437 | $suppress = $wpdb->suppress_errors(); |
| 438 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from a constant list; runs until the backfill is scheduled or marked done. |
| 439 | $row = $wpdb->get_var( "SELECT 1 FROM `{$wpdb->prefix}{$table}` LIMIT 1" ); |
| 440 | $wpdb->suppress_errors( $suppress ); |
| 441 | |
| 442 | return null !== $row; |
| 443 | } |
| 444 | |
| 445 | /** |
| 446 | * Whether the site is connected to WordPress.com. |
| 447 | * |
| 448 | * @return bool |
| 449 | */ |
| 450 | protected function is_jetpack_connected(): bool { |
| 451 | return ( new Connection_Manager() )->is_connected(); |
| 452 | } |
| 453 | |
| 454 | /** |
| 455 | * Whether Jetpack allows syncing (not offline, in safe mode, or with sync disabled). |
| 456 | * |
| 457 | * @return bool |
| 458 | */ |
| 459 | protected function is_sync_allowed(): bool { |
| 460 | return Actions::sync_allowed(); |
| 461 | } |
| 462 | |
| 463 | /** |
| 464 | * Whether another full sync is running. |
| 465 | * |
| 466 | * @return bool |
| 467 | */ |
| 468 | protected function is_full_sync_running(): bool { |
| 469 | $module = Modules::get_module( 'full-sync' ); |
| 470 | if ( ! $module ) { |
| 471 | return false; |
| 472 | } |
| 473 | '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module'; |
| 474 | |
| 475 | return $module->is_started() && ! $module->is_finished(); |
| 476 | } |
| 477 | |
| 478 | /** |
| 479 | * The stored full sync status. |
| 480 | * |
| 481 | * @return array |
| 482 | */ |
| 483 | protected function get_full_sync_status(): array { |
| 484 | $module = Modules::get_module( 'full-sync' ); |
| 485 | if ( ! $module ) { |
| 486 | return array(); |
| 487 | } |
| 488 | '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module'; |
| 489 | |
| 490 | return (array) $module->get_status(); |
| 491 | } |
| 492 | |
| 493 | /** |
| 494 | * IDs of all products and variations. |
| 495 | * |
| 496 | * @return int[] |
| 497 | */ |
| 498 | protected function get_product_ids(): array { |
| 499 | global $wpdb; |
| 500 | |
| 501 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Runs once per store, from the backfill action. |
| 502 | return array_map( 'intval', $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' ) ORDER BY ID" ) ); |
| 503 | } |
| 504 | |
| 505 | /** |
| 506 | * Start the full sync of the order lookup data and the product catalog. |
| 507 | * |
| 508 | * WordPress.com builds its product table from synced product posts and their product_type terms, which it |
| 509 | * ignored before it knew an analytics plugin was active. Modules are sent in this order: terms before posts. |
| 510 | * |
| 511 | * @return bool Whether Jetpack started it. |
| 512 | */ |
| 513 | private function start_full_sync(): bool { |
| 514 | $config = array( 'woocommerce_analytics' => true ); |
| 515 | $product_ids = $this->get_product_ids(); |
| 516 | if ( $product_ids ) { |
| 517 | // term_relationships can't be limited to IDs; posts can, and only products build the catalog. Full sync rewrites its |
| 518 | // status, config included, after every batch, so a large catalog resends all posts, which are then mostly products. |
| 519 | $config += array( |
| 520 | 'terms' => true, |
| 521 | 'term_relationships' => true, |
| 522 | 'posts' => count( $product_ids ) > self::MAX_PRODUCT_IDS ? true : $product_ids, |
| 523 | ); |
| 524 | } |
| 525 | |
| 526 | return $this->do_full_sync( $config ); |
| 527 | } |
| 528 | |
| 529 | /** |
| 530 | * Start a full sync with the backfill's context. |
| 531 | * |
| 532 | * @param array $config Full sync config by module name. |
| 533 | * @return bool Whether Jetpack started it. |
| 534 | */ |
| 535 | protected function do_full_sync( array $config ): bool { |
| 536 | return Actions::do_full_sync( $config, self::FULL_SYNC_CONTEXT ); |
| 537 | } |
| 538 | } |