JezK
Edit File: AnalyticsImports.php
<?php /** * REST API Analytics Imports Controller * * Handles requests to get batch import status and trigger manual imports. */ declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API; use WP_Error; use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler; defined( 'ABSPATH' ) || exit; /** * REST API Analytics Imports Controller. * * @internal */ class AnalyticsImports extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'imports'; /** * Register routes. * * @return void */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base . '/status', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_status' ), 'permission_callback' => array( $this, 'permissions_check' ), ), 'schema' => array( $this, 'get_status_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/trigger', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'trigger_import' ), 'permission_callback' => array( $this, 'permissions_check' ), ), 'schema' => array( $this, 'get_trigger_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/retry-failed', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'retry_failed_imports' ), 'permission_callback' => array( $this, 'permissions_check' ), ), 'schema' => array( $this, 'get_retry_failed_schema' ), ) ); } /** * Check if a given request has access to analytics imports. * * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request. * @return WP_Error|boolean */ public function permissions_check( $request ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { return new WP_Error( 'woocommerce_rest_cannot_access', __( 'Sorry, you cannot access analytics imports.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Get the current import status. * * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request. * @return \WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_status( $request ) { $is_scheduled_mode = $this->is_scheduled_import_enabled(); $mode = $is_scheduled_mode ? 'scheduled' : 'immediate'; $failed_imports = OrdersScheduler::get_failed_order_imports(); $response = array( 'mode' => $mode, 'last_processed_date' => null, 'next_scheduled' => null, 'import_in_progress_or_due' => null, 'failed_count' => count( $failed_imports['ids'] ), 'failed_overflow_count' => $failed_imports['overflow'], ); // For scheduled mode, populate additional fields. if ( $is_scheduled_mode ) { $last_processed_gmt = get_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION, null ); $response['last_processed_date'] = ( is_string( $last_processed_gmt ) && $last_processed_gmt ) ? get_date_from_gmt( $last_processed_gmt, 'Y-m-d H:i:s' ) : null; $response['next_scheduled'] = $this->get_next_scheduled_time(); $response['import_in_progress_or_due'] = $this->is_import_in_progress_or_due(); } return rest_ensure_response( $response ); } /** * Trigger a manual import. * * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request. * @return \WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function trigger_import( $request ) { $is_scheduled_mode = $this->is_scheduled_import_enabled(); // Return error if in immediate mode. if ( ! $is_scheduled_mode ) { return new WP_Error( 'woocommerce_rest_analytics_import_immediate_mode', __( 'Manual import is not available in immediate mode. Imports happen automatically.', 'woocommerce' ), array( 'status' => 400 ) ); } // Check if an import is already in progress or due to run soon. if ( $this->is_import_in_progress_or_due() ) { return new WP_Error( 'woocommerce_rest_analytics_import_in_progress', __( 'A batch import is already in progress or scheduled to run soon. Please wait for it to complete before triggering a new import.', 'woocommerce' ), array( 'status' => 400 ) ); } // Trigger the batch import immediately by rescheduling the recurring processor. // This unschedules the current recurring action and reschedules it to run now. $action_hook = OrdersScheduler::get_action( OrdersScheduler::PROCESS_PENDING_ORDERS_BATCH_ACTION ); if ( ! is_string( $action_hook ) ) { return new WP_Error( 'woocommerce_rest_analytics_import_invalid_action', __( 'Invalid action hook for batch import.', 'woocommerce' ), array( 'status' => 500 ) ); } WC()->queue()->cancel_all( $action_hook, array(), (string) OrdersScheduler::$group ); OrdersScheduler::schedule_recurring_batch_processor(); return rest_ensure_response( array( 'success' => true, 'message' => __( 'Batch import triggered successfully.', 'woocommerce' ), ) ); } /** * Re-schedule imports for orders that previously failed. * * Order IDs whose orders no longer exist are pruned (they can never import * successfully). Orders with an import already pending are skipped and * reported separately, so repeated requests don't claim to schedule new * work. The remaining IDs stay recorded until their import succeeds, so a * retry that fails again remains visible. * * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request. * @return \WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function retry_failed_imports( $request ) { $failed = OrdersScheduler::get_failed_order_imports(); if ( empty( $failed['ids'] ) ) { return new WP_Error( 'woocommerce_rest_analytics_no_failed_imports', __( 'There are no failed order imports to retry.', 'woocommerce' ), array( 'status' => 400 ) ); } $retried_count = 0; $pruned_count = 0; $already_scheduled_count = 0; $error_count = 0; foreach ( $failed['ids'] as $order_id ) { if ( ! wc_get_order( $order_id ) ) { OrdersScheduler::clear_failed_order_import( $order_id ); ++$pruned_count; continue; } // schedule_action() silently no-ops when the same import is // already pending, so check first to report an accurate count. if ( OrdersScheduler::has_existing_jobs( 'import', array( $order_id ) ) ) { ++$already_scheduled_count; continue; } try { OrdersScheduler::schedule_action( 'import', array( $order_id ) ); ++$retried_count; } catch ( \Throwable $e ) { // schedule_action() may run the import synchronously (e.g. when // Action Scheduler is unavailable); a failing order must not // abort the whole retry request. ++$error_count; wc_get_logger()->error( sprintf( 'Failed to schedule analytics re-import for order %d: %s', $order_id, $e->getMessage() ), array( 'source' => 'wc-analytics-order-import' ) ); } } // Nothing was scheduled and nothing is pending: surface the failure // instead of reporting success for work that didn't happen. if ( 0 === $retried_count && 0 === $already_scheduled_count && $error_count > 0 ) { return new WP_Error( 'woocommerce_rest_analytics_retry_failed', __( 'The failed orders could not be scheduled for re-import. Check the order import log for details.', 'woocommerce' ), array( 'status' => 500 ) ); } if ( $retried_count > 0 ) { $message = sprintf( /* translators: %d: number of orders scheduled for re-import */ _n( 'Re-import scheduled for %d order.', 'Re-import scheduled for %d orders.', $retried_count, 'woocommerce' ), $retried_count ); } elseif ( $already_scheduled_count > 0 ) { $message = __( 'Re-import is already scheduled for the previously failed orders.', 'woocommerce' ); } else { $message = __( 'No orders were scheduled for re-import. The previously failed orders no longer exist.', 'woocommerce' ); } if ( $error_count > 0 ) { $message .= ' ' . sprintf( /* translators: %d: number of orders that could not be scheduled for re-import */ _n( '%d order could not be scheduled. Check the order import log for details.', '%d orders could not be scheduled. Check the order import log for details.', $error_count, 'woocommerce' ), $error_count ); } return rest_ensure_response( array( 'success' => true, 'message' => $message, 'retried_count' => $retried_count, 'pruned_count' => $pruned_count, 'already_scheduled_count' => $already_scheduled_count, 'error_count' => $error_count, ) ); } /** * Get the schema for the retry-failed endpoint, conforming to JSON Schema. * * @return array */ public function get_retry_failed_schema() { $schema = array( '$schema' => 'https://json-schema.org/draft-04/schema#', 'title' => 'analytics_import_retry_failed', 'type' => 'object', 'properties' => array( 'success' => array( 'type' => 'boolean', 'description' => __( 'Whether the retry was scheduled successfully.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'message' => array( 'type' => 'string', 'description' => __( 'Result message.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'retried_count' => array( 'type' => 'integer', 'description' => __( 'Number of orders scheduled for re-import.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'pruned_count' => array( 'type' => 'integer', 'description' => __( 'Number of failed records removed because their orders no longer exist.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'already_scheduled_count' => array( 'type' => 'integer', 'description' => __( 'Number of orders skipped because their re-import is already pending.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'error_count' => array( 'type' => 'integer', 'description' => __( 'Number of orders that could not be scheduled for re-import.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Check if scheduled import is enabled. * * Delegates to OrdersScheduler so the API reflects the same mode the * scheduler actually runs in (feature flag check + legacy option fallback). * * @return bool */ private function is_scheduled_import_enabled() { return OrdersScheduler::is_scheduled_import_enabled(); } /** * Get the next scheduled time for the batch processor. * * @return string|null Datetime string in site timezone or null if not scheduled. */ private function get_next_scheduled_time() { $action_hook = OrdersScheduler::get_action( OrdersScheduler::PROCESS_PENDING_ORDERS_BATCH_ACTION ); if ( ! is_string( $action_hook ) ) { return null; } $next_time = WC()->queue()->get_next( $action_hook, array(), (string) OrdersScheduler::$group ); if ( ! $next_time ) { return null; } // Convert UTC timestamp to site timezone. return get_date_from_gmt( $next_time->format( 'Y-m-d H:i:s' ), 'Y-m-d H:i:s' ); } /** * Get the schema for the status endpoint, conforming to JSON Schema. * * @return array */ public function get_status_schema() { $schema = array( '$schema' => 'https://json-schema.org/draft-04/schema#', 'title' => 'analytics_import_status', 'type' => 'object', 'properties' => array( 'mode' => array( 'type' => 'string', 'enum' => array( 'scheduled', 'immediate' ), 'description' => __( 'Current import mode.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'last_processed_date' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'Last processed order date (null in immediate mode).', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'next_scheduled' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'Next scheduled import time (null in immediate mode).', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'import_in_progress_or_due' => array( 'type' => array( 'boolean', 'null' ), 'description' => __( 'Whether a batch import is currently running or scheduled to run within the next minute (null in immediate mode).', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'failed_count' => array( 'type' => 'integer', 'description' => __( 'Number of orders that failed analytics import and are pending retry.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'failed_overflow_count' => array( 'type' => 'integer', 'description' => __( 'Number of failed order IDs dropped because the stored list reached its limit.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the schema for the trigger endpoint, conforming to JSON Schema. * * @return array */ public function get_trigger_schema() { $schema = array( '$schema' => 'https://json-schema.org/draft-04/schema#', 'title' => 'analytics_import_trigger', 'type' => 'object', 'properties' => array( 'success' => array( 'type' => 'boolean', 'description' => __( 'Whether the trigger was successful.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'message' => array( 'type' => 'string', 'description' => __( 'Result message.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Check if a batch import is currently in progress or due to run soon. * * @return bool True if a batch import is in progress or scheduled to run within the next minute, false otherwise. */ private function is_import_in_progress_or_due() { $hook = OrdersScheduler::get_action( OrdersScheduler::PROCESS_PENDING_ORDERS_BATCH_ACTION ); if ( ! is_string( $hook ) ) { return false; } // Check for actions with 'in-progress' status. $in_progress_actions = WC()->queue()->search( array( 'hook' => $hook, 'status' => 'in-progress', 'per_page' => 1, ), 'ids' ); if ( ! empty( $in_progress_actions ) ) { return true; } // Check if the next scheduled import is due within 1 minute. $next_scheduled = WC()->queue()->get_next( $hook, array(), (string) OrdersScheduler::$group ); if ( $next_scheduled ) { $time_until_next = $next_scheduled->getTimestamp() - time(); // Consider it "due" if it's scheduled to run within the next 60 seconds. if ( $time_until_next <= MINUTE_IN_SECONDS ) { return true; } } return false; } }