PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 11.0.0
WooCommerce v11.0.0
11.1.0 11.1.0-rc.2 11.1.0-rc.1 11.1.0-beta.2 11.1.0-beta.1 11.0.1 11.0.0 11.0.0-rc.3 11.0.0-rc.2 11.0.0-rc.1 11.0.0-beta.2 11.0.0-beta.1 10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / Admin / Schedulers / OrdersScheduler.php
woocommerce / src / Internal / Admin / Schedulers Last commit date
CustomersScheduler.php 3 years ago ImportInterface.php 4 years ago ImportScheduler.php 2 months ago MailchimpScheduler.php 1 year ago OrdersScheduler.php 1 month ago
OrdersScheduler.php
953 lines
1 <?php
2 /**
3 * Order syncing related functions and actions.
4 */
5
6 namespace Automattic\WooCommerce\Internal\Admin\Schedulers;
7
8 defined( 'ABSPATH' ) || exit;
9
10 use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
11 use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
12 use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
13 use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrderDataStore;
14 use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
15 use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore;
16 use Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore as TaxesDataStore;
17 use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
18 use Automattic\WooCommerce\Utilities\OrderUtil;
19 use Automattic\WooCommerce\Admin\Features\Features;
20
21 /**
22 * OrdersScheduler Class.
23 */
24 class OrdersScheduler extends ImportScheduler {
25 /**
26 * Slug to identify the scheduler.
27 *
28 * @var string
29 */
30 public static $name = 'orders';
31
32 /**
33 * Option name for storing the last processed order modified date.
34 *
35 * This is used as a cursor to track progress through the orders table.
36 * We need both date and ID because multiple orders can have the same
37 * date_updated timestamp (e.g., bulk operations, imports). Without tracking
38 * the ID, we would endlessly reprocess orders at the same timestamp when
39 * the batch size is smaller than the number of orders at that timestamp.
40 *
41 * @var string
42 */
43 const LAST_PROCESSED_ORDER_DATE_OPTION = 'woocommerce_admin_scheduler_last_processed_order_modified_date';
44
45 /**
46 * Option name for storing the last processed order ID.
47 *
48 * Used in conjunction with LAST_PROCESSED_ORDER_DATE_OPTION to handle
49 * cases where multiple orders have the same date_updated timestamp.
50 * Query pattern: WHERE (date > last_date) OR (date = last_date AND id > last_id)
51 *
52 * @var string
53 */
54 const LAST_PROCESSED_ORDER_ID_OPTION = 'woocommerce_admin_scheduler_last_processed_order_id';
55
56 /**
57 * Option name for storing whether to enable scheduled order import.
58 *
59 * @var string
60 */
61 const SCHEDULED_IMPORT_OPTION = 'woocommerce_analytics_scheduled_import';
62
63 /**
64 * Legacy option name before the rename in 10.5.0.
65 *
66 * Used as a fallback during upgrades before the migration routine runs.
67 * The old option stored inverted semantics: 'yes' = immediate, 'no' = scheduled.
68 *
69 * @var string
70 */
71 const LEGACY_IMMEDIATE_IMPORT_OPTION = 'woocommerce_analytics_immediate_import';
72
73 /**
74 * Default value for the scheduled import option.
75 *
76 * @var string
77 */
78 const SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE = 'no';
79
80 /**
81 * Action name for the order batch import.
82 *
83 * @var string
84 */
85 const PROCESS_PENDING_ORDERS_BATCH_ACTION = 'process_pending_batch';
86
87 /**
88 * Option name for storing order IDs that failed analytics import.
89 *
90 * The skip-and-advance behavior in process_pending_batch() means a failing
91 * order is excluded from analytics. The IDs are persisted here so the
92 * "Import historical data" UI can surface them and offer a targeted retry.
93 *
94 * Shape: array( 'ids' => int[], 'overflow' => int ). 'overflow' counts IDs
95 * dropped because the list reached FAILED_ORDER_IMPORTS_CAP.
96 *
97 * Updates to this option are best-effort, not atomic: a concurrent
98 * read-modify-write (e.g. the batch processor recording a failure while a
99 * retry request prunes an ID) can lose one of the writes. This is accepted
100 * because the list is advisory and self-healing — a stale ID is cleared on
101 * the order's next successful import, and every failure is also logged to
102 * the 'wc-analytics-order-import' source. If stronger guarantees are ever
103 * needed, store each failed ID as its own row instead.
104 *
105 * @var string
106 */
107 const FAILED_ORDER_IMPORTS_OPTION = 'woocommerce_admin_analytics_failed_order_imports';
108
109 /**
110 * Maximum number of failed order IDs to store.
111 *
112 * @var int
113 */
114 const FAILED_ORDER_IMPORTS_CAP = 1000;
115
116 /**
117 * Attach order lookup update hooks.
118 *
119 * @internal
120 */
121 public static function init() {
122 // Activate WC_Order extension.
123 \Automattic\WooCommerce\Admin\Overrides\Order::add_filters();
124 \Automattic\WooCommerce\Admin\Overrides\OrderRefund::add_filters();
125
126 if ( self::is_scheduled_import_enabled() ) {
127 // Schedule recurring batch processor.
128 add_action( 'action_scheduler_ensure_recurring_actions', array( __CLASS__, 'schedule_recurring_batch_processor' ) );
129 } else {
130 // Schedule import immediately on order create/update/delete.
131 add_action( 'woocommerce_update_order', array( __CLASS__, 'possibly_schedule_import' ) );
132 add_filter( 'woocommerce_create_order', array( __CLASS__, 'possibly_schedule_import' ) );
133 add_action( 'woocommerce_refund_created', array( __CLASS__, 'possibly_schedule_import' ) );
134 add_action( 'woocommerce_schedule_import', array( __CLASS__, 'possibly_schedule_import' ) );
135 }
136
137 if ( Features::is_enabled( 'analytics-scheduled-import' ) ) {
138 // Watch for changes to the scheduled import option.
139 add_action( 'add_option_' . self::SCHEDULED_IMPORT_OPTION, array( __CLASS__, 'handle_scheduled_import_option_added' ), 10, 2 );
140 add_action( 'update_option_' . self::SCHEDULED_IMPORT_OPTION, array( __CLASS__, 'handle_scheduled_import_option_change' ), 10, 2 );
141 add_action( 'delete_option', array( __CLASS__, 'handle_scheduled_import_option_before_delete' ), 10, 1 );
142 }
143
144 OrdersStatsDataStore::init();
145 CouponsDataStore::init();
146 ProductsDataStore::init();
147 TaxesDataStore::init();
148 OrderDataStore::init();
149
150 parent::init();
151 }
152
153 /**
154 * Add customer dependencies.
155 *
156 * @internal
157 * @return array
158 */
159 public static function get_dependencies() {
160 return array(
161 'import_batch_init' => \Automattic\WooCommerce\Internal\Admin\Schedulers\CustomersScheduler::get_action( 'import_batch_init' ),
162 );
163 }
164
165 /**
166 * Get all available scheduling actions.
167 * Extends parent to add the new batch processor action.
168 *
169 * @internal
170 * @return array
171 */
172 public static function get_scheduler_actions() {
173 return array_merge(
174 parent::get_scheduler_actions(),
175 array(
176 self::PROCESS_PENDING_ORDERS_BATCH_ACTION => 'wc-admin_process_pending_orders_batch',
177 )
178 );
179 }
180
181 /**
182 * Get batch sizes for OrdersScheduler actions.
183 *
184 * @internal
185 * @return array
186 */
187 public static function get_batch_sizes() {
188 return array_merge(
189 parent::get_batch_sizes(),
190 array(
191 self::PROCESS_PENDING_ORDERS_BATCH_ACTION => 100,
192 )
193 );
194 }
195
196 /**
197 * Get the order/refund IDs and total count that need to be synced.
198 *
199 * @internal
200 * @param int $limit Number of records to retrieve.
201 * @param int $page Page number.
202 * @param int|bool $days Number of days prior to current date to limit search results.
203 * @param bool $skip_existing Skip already imported orders.
204 */
205 public static function get_items( $limit = 10, $page = 1, $days = false, $skip_existing = false ) {
206 if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
207 return self::get_items_from_orders_table( $limit, $page, $days, $skip_existing );
208 } else {
209 return self::get_items_from_posts_table( $limit, $page, $days, $skip_existing );
210 }
211 }
212
213 /**
214 * Helper method to ger order/refund IDS and total count that needs to be synced.
215 *
216 * @internal
217 * @param int $limit Number of records to retrieve.
218 * @param int $page Page number.
219 * @param int|bool $days Number of days prior to current date to limit search results.
220 * @param bool $skip_existing Skip already imported orders.
221 *
222 * @return object Total counts.
223 */
224 private static function get_items_from_posts_table( $limit, $page, $days, $skip_existing ) {
225 global $wpdb;
226 $where_clause = '';
227 $offset = $page > 1 ? ( $page - 1 ) * $limit : 0;
228
229 if ( is_int( $days ) ) {
230 $days_ago = gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) );
231 $where_clause .= " AND post_date_gmt >= '{$days_ago}'";
232 }
233
234 if ( $skip_existing ) {
235 $where_clause .= " AND NOT EXISTS (
236 SELECT 1 FROM {$wpdb->prefix}wc_order_stats
237 WHERE {$wpdb->prefix}wc_order_stats.order_id = {$wpdb->posts}.ID
238 )";
239 }
240
241 $count = $wpdb->get_var(
242 "SELECT COUNT(*) FROM {$wpdb->posts}
243 WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
244 AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
245 {$where_clause}" // phpcs:ignore unprepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared SQL ok.
246 );
247
248 $order_ids = absint( $count ) > 0 ? $wpdb->get_col(
249 $wpdb->prepare(
250 "SELECT ID FROM {$wpdb->posts}
251 WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
252 AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
253 {$where_clause}
254 ORDER BY post_date_gmt ASC
255 LIMIT %d
256 OFFSET %d",
257 $limit,
258 $offset
259 )
260 ) : array(); // phpcs:ignore unprepared SQL ok.
261
262 return (object) array(
263 'total' => absint( $count ),
264 'ids' => $order_ids,
265 );
266 }
267
268 /**
269 * Helper method to ger order/refund IDS and total count that needs to be synced from HPOS.
270 *
271 * @internal
272 * @param int $limit Number of records to retrieve.
273 * @param int $page Page number.
274 * @param int|bool $days Number of days prior to current date to limit search results.
275 * @param bool $skip_existing Skip already imported orders.
276 *
277 * @return object Total counts.
278 */
279 private static function get_items_from_orders_table( $limit, $page, $days, $skip_existing ) {
280 global $wpdb;
281 $where_clause = '';
282 $offset = $page > 1 ? ( $page - 1 ) * $limit : 0;
283 $order_table = OrdersTableDataStore::get_orders_table_name();
284
285 if ( is_int( $days ) ) {
286 $days_ago = gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) );
287 $where_clause .= " AND orders.date_created_gmt >= '{$days_ago}'";
288 }
289
290 if ( $skip_existing ) {
291 $where_clause .= "AND NOT EXiSTS (
292 SELECT 1 FROM {$wpdb->prefix}wc_order_stats
293 WHERE {$wpdb->prefix}wc_order_stats.order_id = orders.id
294 )
295 ";
296 }
297
298 $count = $wpdb->get_var(
299 "
300 SELECT COUNT(*) FROM {$order_table} AS orders
301 WHERE type in ( 'shop_order', 'shop_order_refund' )
302 AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
303 {$where_clause}
304 "
305 ); // phpcs:ignore unprepared SQL ok.
306
307 $order_ids = absint( $count ) > 0 ? $wpdb->get_col(
308 $wpdb->prepare(
309 "SELECT id FROM {$order_table} AS orders
310 WHERE type IN ( 'shop_order', 'shop_order_refund' )
311 AND status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
312 {$where_clause}
313 ORDER BY date_created_gmt ASC
314 LIMIT %d
315 OFFSET %d",
316 $limit,
317 $offset
318 )
319 ) : array(); // phpcs:ignore unprepared SQL ok.
320
321 return (object) array(
322 'total' => absint( $count ),
323 'ids' => $order_ids,
324 );
325 }
326
327 /**
328 * Get total number of rows imported.
329 *
330 * @internal
331 */
332 public static function get_total_imported() {
333 global $wpdb;
334 return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats" );
335 }
336
337 /**
338 * Schedule this import if the post is an order or refund.
339 * Note: This method is only called when scheduled import is disabled
340 * (immediate mode). Otherwise, orders are processed in batches periodically.
341 *
342 * @param int $order_id Post ID.
343 *
344 * @internal
345 * @returns int The order id
346 */
347 public static function possibly_schedule_import( $order_id ) {
348 if ( self::is_scheduled_import_enabled() ) {
349 return $order_id;
350 }
351
352 if ( ! OrderUtil::is_order( $order_id, array( 'shop_order' ) ) && 'woocommerce_refund_created' !== current_filter() && 'woocommerce_schedule_import' !== current_filter() ) {
353 return $order_id;
354 }
355
356 self::schedule_action( 'import', array( $order_id ) );
357 return $order_id;
358 }
359
360 /**
361 * Imports a single order or refund to update lookup tables for.
362 * If an error is encountered in one of the updates, a retry action is scheduled.
363 *
364 * @internal
365 * @param int $order_id Order or refund ID.
366 * @return void
367 */
368 public static function import( $order_id ) {
369 $order = wc_get_order( $order_id );
370
371 // If the order isn't found for some reason, skip the sync.
372 if ( ! $order ) {
373 return;
374 }
375
376 $type = $order->get_type();
377
378 // If the order isn't the right type, skip sync.
379 if ( 'shop_order' !== $type && 'shop_order_refund' !== $type ) {
380 return;
381 }
382
383 // If the order has no id or date created, skip sync.
384 if ( ! $order->get_id() || ! $order->get_date_created() ) {
385 return;
386 }
387
388 // Skip test orders (e.g., WCPay test mode) from analytics.
389 if ( self::is_test_order( $order ) ) {
390 wc_get_logger()->debug(
391 sprintf( 'Skipping test order #%d from analytics import.', $order_id ),
392 array( 'source' => 'wc-analytics-order-import' )
393 );
394 return;
395 }
396
397 $results = array(
398 OrdersStatsDataStore::sync_order( $order_id ),
399 ProductsDataStore::sync_order_products( $order_id ),
400 CouponsDataStore::sync_order_coupons( $order_id ),
401 TaxesDataStore::sync_order_taxes( $order_id ),
402 CustomersDataStore::sync_order_customer( $order_id ),
403 );
404
405 if ( 'shop_order' === $type ) {
406 $order_refunds = $order->get_refunds();
407
408 foreach ( $order_refunds as $refund ) {
409 OrdersStatsDataStore::sync_order( $refund->get_id() );
410 }
411 }
412
413 ReportsCache::invalidate();
414
415 // A successful import means the order is no longer missing from analytics.
416 self::clear_failed_order_import( $order_id );
417
418 /**
419 * Fires after an order or refund has been imported into Analytics lookup tables
420 * and the reports cache has been invalidated.
421 *
422 * @since 10.3.0
423 * @param int $order_id Order or refund ID.
424 */
425 do_action( 'woocommerce_order_scheduler_after_import_order', $order_id );
426 }
427
428 /**
429 * Schedule recurring batch processor for order imports.
430 *
431 * @internal
432 */
433 public static function schedule_recurring_batch_processor() {
434 $action_hook = self::get_action( self::PROCESS_PENDING_ORDERS_BATCH_ACTION );
435 if ( null === $action_hook ) {
436 return;
437 }
438 // The most efficient way to check for an existing action is to use `as_has_scheduled_action`, but in unusual
439 // cases where another plugin has loaded a very old version of Action Scheduler, it may not be available to us.
440 $has_scheduled_action = function_exists( 'as_has_scheduled_action' ) ? 'as_has_scheduled_action' : 'as_next_scheduled_action';
441 if ( call_user_func( $has_scheduled_action, $action_hook ) ) {
442 return;
443 }
444
445 $interval = self::get_import_interval();
446
447 as_schedule_recurring_action( time(), $interval, $action_hook, array(), static::$group ?? '', true );
448 }
449
450 /**
451 * Handle changes to the scheduled import option.
452 *
453 * When switching from scheduled to immediate import,
454 * we need to run a final catchup batch to ensure no orders are missed.
455 *
456 * When switching from immediate to scheduled import,
457 * we need to reschedule the recurring batch processor.
458 *
459 * @internal
460 * @param mixed $old_value The old value of the option.
461 * @param mixed $new_value The new value of the option.
462 * @return void
463 */
464 public static function handle_scheduled_import_option_change( $old_value, $new_value ) {
465 // If switching from scheduled to immediate import.
466 if ( 'yes' === $old_value && 'no' === $new_value ) {
467 // Unschedule the recurring batch processor.
468 $action_hook = self::get_action( self::PROCESS_PENDING_ORDERS_BATCH_ACTION );
469 if ( null !== $action_hook ) {
470 as_unschedule_all_actions( $action_hook, array(), static::$group ?? '' );
471 }
472
473 // Schedule an immediate catchup batch to process all orders up to now.
474 // This ensures no orders are missed during the transition.
475 self::schedule_action( self::PROCESS_PENDING_ORDERS_BATCH_ACTION, array( null, null ) );
476 } elseif ( 'no' === $old_value && 'yes' === $new_value ) {
477 // Switching from immediate to scheduled import.
478 // Set the last processed order date to now with 1 minute buffer to ensure no orders are missed.
479 update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, gmdate( 'Y-m-d H:i:s', time() - MINUTE_IN_SECONDS ) );
480 update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0 );
481
482 // Schedule the recurring batch processor.
483 self::schedule_recurring_batch_processor();
484 }
485 }
486
487 /**
488 * Handle addition of the scheduled import option.
489 *
490 * @internal
491 * @param string $option_name The name of the option that was added.
492 * @param string $value The value of the option that was added.
493 *
494 * @return void
495 */
496 public static function handle_scheduled_import_option_added( $option_name, $value ) {
497 if ( self::SCHEDULED_IMPORT_OPTION !== $option_name ) {
498 return;
499 }
500
501 self::handle_scheduled_import_option_change( self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE, $value );
502 }
503
504 /**
505 * Handle deletion of the scheduled import option.
506 *
507 * @internal
508 * @param string $option_name The name of the option that was deleted.
509 *
510 * @return void
511 */
512 public static function handle_scheduled_import_option_before_delete( $option_name ) {
513 if ( self::SCHEDULED_IMPORT_OPTION !== $option_name ) {
514 return;
515 }
516
517 self::handle_scheduled_import_option_change(
518 get_option( self::SCHEDULED_IMPORT_OPTION, self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE ),
519 self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE,
520 );
521 }
522
523 /**
524 * Process pending orders in batch.
525 *
526 * This method queries for orders updated since the last cursor position
527 * (compound cursor: date + ID) and imports them into the analytics tables.
528 *
529 * @internal
530 * @param string|null $cursor_date Cursor date in 'Y-m-d H:i:s' format. Orders after this date will be processed.
531 * @param int|null $cursor_id Cursor order ID. Combined with $cursor_date to form compound cursor.
532 * @return void
533 */
534 public static function process_pending_batch( $cursor_date = null, $cursor_id = null ) {
535 $logger = wc_get_logger();
536 $context = array( 'source' => 'wc-analytics-order-import' );
537
538 if ( self::is_importing() ) {
539 // No need to process if an import is already in progress.
540 $logger->info( 'Import is already in progress, skipping batch import.', $context );
541 return;
542 }
543
544 // Load cursor position from options if not provided.
545 // If the cursor date is not provided, use the last 24 hours as the default since `action_scheduler_ensure_recurring_actions` runs daily so 24 hours is enough.
546 $default_cursor_date = gmdate( 'Y-m-d H:i:s', strtotime( '-24 hours' ) );
547 $cursor_date = $cursor_date ?? get_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, $default_cursor_date );
548 $cursor_id = $cursor_id ?? (int) get_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0 );
549
550 // Validate cursor date.
551 if ( ! $cursor_date || ! strtotime( $cursor_date ) ) {
552 $logger->error( 'Invalid cursor date: ' . $cursor_date, $context );
553 $cursor_date = $default_cursor_date;
554 }
555
556 $batch_size = self::get_batch_size( self::PROCESS_PENDING_ORDERS_BATCH_ACTION );
557
558 $logger->info(
559 sprintf( 'Starting batch import. Cursor: %s (ID: %d), batch size: %d', $cursor_date, $cursor_id, $batch_size ),
560 $context
561 );
562
563 $start_time = microtime( true );
564
565 // Get orders updated since the cursor position.
566 $orders = self::get_orders_since( $cursor_date, $cursor_id, $batch_size );
567
568 if ( empty( $orders ) ) {
569 $logger->info( 'No orders to process', $context );
570 // Update the cursor position to the start time of the batch so that the next batch will start from that point.
571 update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, gmdate( 'Y-m-d H:i:s', (int) $start_time ), false );
572 update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0, false );
573 return;
574 }
575
576 $orders_count = count( $orders );
577 $processed_count = 0;
578 foreach ( $orders as $order ) {
579 try {
580 self::import( $order->id );
581 ++$processed_count;
582
583 // Advance cursor after each successful import. Since orders are sorted by
584 // date ASC, id ASC, we can simply overwrite with the current order's values.
585 $cursor_date = $order->date_updated_gmt;
586 $cursor_id = $order->id;
587 } catch ( \Throwable $e ) {
588 // Log the failure and advance the cursor past the failing order so that
589 // it is skipped on the next run rather than blocking the entire pipeline.
590 static::log_import_error( $order->id, $e, $context );
591 static::record_failed_order_import( $order->id );
592 $cursor_date = $order->date_updated_gmt;
593 $cursor_id = $order->id;
594 }
595 }
596
597 // Save the updated cursor position.
598 update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, $cursor_date, false );
599 update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, $cursor_id, false );
600
601 $elapsed_time = microtime( true ) - $start_time;
602 $logger->info(
603 sprintf(
604 'Batch import completed. Processed: %d/%d orders in %.2f seconds. Cursor: %s (ID: %d)',
605 $processed_count,
606 $orders_count,
607 $elapsed_time,
608 $cursor_date,
609 $cursor_id
610 ),
611 $context
612 );
613
614 // If we fetched a full batch, there might be more orders to process.
615 // Use the fetched count rather than successful count so that skipped
616 // failing orders do not suppress scheduling of the next batch.
617 if ( $orders_count === $batch_size ) {
618 $logger->info( 'Full batch processed, scheduling next batch', $context );
619 self::schedule_action(
620 'process_pending_batch',
621 array( $cursor_date, $cursor_id )
622 );
623 }
624 }
625
626 /**
627 * Get the import interval.
628 *
629 * @internal
630 * @return int The import interval in seconds.
631 */
632 public static function get_import_interval() {
633 /**
634 * Filter the analytics import interval.
635 *
636 * @since 10.4.0
637 * @param int $interval The import interval in seconds. Default is 12 hours.
638 */
639 return apply_filters( 'woocommerce_analytics_import_interval', 12 * HOUR_IN_SECONDS );
640 }
641
642 /**
643 * Get orders updated since the specified cursor position.
644 *
645 * Uses a compound cursor (date + ID) to handle cases where multiple orders
646 * have the same timestamp. This ensures we can paginate through orders reliably
647 * even when batch_size < number of orders at the same timestamp.
648 *
649 * @internal
650 * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format.
651 * @param int $cursor_id Cursor order ID.
652 * @param int $limit Number of orders to retrieve.
653 * @return array Array of objects with 'id' and 'date_updated_gmt' properties.
654 */
655 private static function get_orders_since( $cursor_date, $cursor_id, $limit ) {
656 if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
657 return self::get_orders_since_from_orders_table( $cursor_date, $cursor_id, $limit );
658 } else {
659 return self::get_orders_since_from_posts_table( $cursor_date, $cursor_id, $limit );
660 }
661 }
662
663 /**
664 * Get orders from HPOS orders table updated since the specified cursor position.
665 *
666 * Query logic uses a compound cursor (date, ID) to handle pagination when multiple
667 * orders share the same timestamp:
668 * - WHERE date > cursor_date: Get orders with newer timestamps
669 * - OR (date = cursor_date AND id > cursor_id): Continue processing same timestamp
670 *
671 * Example: With batch_size=100 and 1000 orders at '2024-01-01 10:00:00',
672 * this processes them across 10 batches without infinite loops or duplicates.
673 *
674 * @internal
675 * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format.
676 * @param int $cursor_id Cursor order ID.
677 * @param int $limit Number of orders to retrieve.
678 * @return array Array of objects with 'id' and 'date_updated_gmt' properties.
679 */
680 private static function get_orders_since_from_orders_table( $cursor_date, $cursor_id, $limit ) {
681 global $wpdb;
682 $orders_table = OrdersTableDataStore::get_orders_table_name();
683
684 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
685 return $wpdb->get_results(
686 $wpdb->prepare(
687 "SELECT id, date_updated_gmt
688 FROM {$orders_table}
689 WHERE type IN ('shop_order', 'shop_order_refund')
690 AND status NOT IN ('wc-auto-draft', 'auto-draft', 'trash')
691 AND (
692 date_updated_gmt > %s
693 OR (date_updated_gmt = %s AND id > %d)
694 )
695 ORDER BY date_updated_gmt ASC, id ASC
696 LIMIT %d",
697 $cursor_date,
698 $cursor_date,
699 $cursor_id,
700 $limit
701 )
702 );
703 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
704 }
705
706 /**
707 * Get orders from posts table updated since the specified cursor position.
708 *
709 * Uses the same compound cursor logic as get_orders_since_from_orders_table()
710 * but queries the posts table instead of the HPOS orders table.
711 *
712 * @internal
713 * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format.
714 * @param int $cursor_id Cursor order ID.
715 * @param int $limit Number of orders to retrieve.
716 * @return array Array of objects with 'id' and 'date_updated_gmt' properties.
717 */
718 private static function get_orders_since_from_posts_table( $cursor_date, $cursor_id, $limit ) {
719 global $wpdb;
720
721 return $wpdb->get_results(
722 $wpdb->prepare(
723 "SELECT ID as id, post_modified_gmt as date_updated_gmt
724 FROM {$wpdb->posts}
725 WHERE post_type IN ('shop_order', 'shop_order_refund')
726 AND post_status NOT IN ('wc-auto-draft', 'auto-draft', 'trash')
727 AND (
728 post_modified_gmt > %s
729 OR (post_modified_gmt = %s AND ID > %d)
730 )
731 ORDER BY post_modified_gmt ASC, ID ASC
732 LIMIT %d",
733 $cursor_date,
734 $cursor_date,
735 $cursor_id,
736 $limit
737 )
738 );
739 }
740
741 /**
742 * Check if an order is a test order that should be excluded from analytics.
743 *
744 * For refunds, the parent order is checked instead, since refunds do not
745 * carry the test mode metadata directly.
746 *
747 * @param \WC_Abstract_Order $order Order object.
748 * @return bool
749 *
750 * @since 10.7.0
751 */
752 public static function is_test_order( $order ) {
753 if ( ! $order instanceof \WC_Abstract_Order ) {
754 return false;
755 }
756
757 // For refunds, check the parent order.
758 $check_order = $order;
759 if ( 'shop_order_refund' === $order->get_type() ) {
760 $check_order = wc_get_order( $order->get_parent_id() );
761 if ( ! $check_order instanceof \WC_Abstract_Order ) {
762 return false;
763 }
764 }
765
766 $is_test = 'test' === $check_order->get_meta( '_wcpay_mode' );
767
768 /**
769 * Filter whether an order is a test order excluded from analytics.
770 *
771 * Use this filter to customize test order detection beyond the default
772 * WCPay test mode check, e.g., to exclude orders from other payment
773 * gateways' test/sandbox modes.
774 *
775 * @param bool $is_test Whether the order is a test order.
776 * @param \WC_Abstract_Order $order The order being checked (for refunds, this is the parent order).
777 *
778 * @since 10.7.0
779 */
780 return apply_filters( 'woocommerce_analytics_is_test_order', $is_test, $check_order );
781 }
782
783 /**
784 * Get the recorded failed order imports.
785 *
786 * @internal
787 * @since 11.0.0
788 * @return array Array with 'ids' (int[]) and 'overflow' (int) keys.
789 */
790 public static function get_failed_order_imports(): array {
791 $value = get_option( self::FAILED_ORDER_IMPORTS_OPTION, array() );
792 if ( ! is_array( $value ) ) {
793 $value = array();
794 }
795
796 return array(
797 'ids' => isset( $value['ids'] ) && is_array( $value['ids'] ) ? array_map( 'absint', $value['ids'] ) : array(),
798 'overflow' => isset( $value['overflow'] ) ? absint( $value['overflow'] ) : 0,
799 );
800 }
801
802 /**
803 * Record an order ID that failed analytics import.
804 *
805 * Deduplicates IDs. When the list exceeds FAILED_ORDER_IMPORTS_CAP, the
806 * oldest ID is dropped and the overflow counter is incremented.
807 *
808 * @internal
809 * @since 11.0.0
810 * @param int $order_id Order or refund ID that failed to import.
811 * @return void
812 */
813 public static function record_failed_order_import( $order_id ): void {
814 $order_id = absint( $order_id );
815 if ( ! $order_id ) {
816 return;
817 }
818
819 $failed = self::get_failed_order_imports();
820 if ( in_array( $order_id, $failed['ids'], true ) ) {
821 return;
822 }
823
824 $failed['ids'][] = $order_id;
825 $ids_count = count( $failed['ids'] );
826 while ( $ids_count > self::FAILED_ORDER_IMPORTS_CAP ) {
827 array_shift( $failed['ids'] );
828 ++$failed['overflow'];
829 --$ids_count;
830 }
831
832 update_option( self::FAILED_ORDER_IMPORTS_OPTION, $failed, false );
833 }
834
835 /**
836 * Remove an order ID from the failed imports list.
837 *
838 * Called after a successful import so the list always reflects orders
839 * that have not been imported since their last failure.
840 *
841 * @internal
842 * @since 11.0.0
843 * @param int $order_id Order or refund ID to remove.
844 * @return void
845 */
846 public static function clear_failed_order_import( $order_id ): void {
847 $order_id = absint( $order_id );
848 if ( ! $order_id ) {
849 return;
850 }
851
852 $failed = self::get_failed_order_imports();
853 $index = array_search( $order_id, $failed['ids'], true );
854
855 if ( false === $index ) {
856 return;
857 }
858
859 unset( $failed['ids'][ $index ] );
860 $failed['ids'] = array_values( $failed['ids'] );
861
862 if ( empty( $failed['ids'] ) && 0 === $failed['overflow'] ) {
863 delete_option( self::FAILED_ORDER_IMPORTS_OPTION );
864 return;
865 }
866
867 update_option( self::FAILED_ORDER_IMPORTS_OPTION, $failed, false );
868 }
869
870 /**
871 * Reset the failed order imports overflow counter.
872 *
873 * Called when a full (non-windowed) historical import starts, since that
874 * import covers the orders whose IDs were dropped from the list. Windowed
875 * imports must not reset the counter.
876 *
877 * @internal
878 * @since 11.0.0
879 * @return void
880 */
881 public static function reset_failed_order_imports_overflow(): void {
882 $failed = self::get_failed_order_imports();
883 if ( 0 === $failed['overflow'] ) {
884 return;
885 }
886
887 $failed['overflow'] = 0;
888 if ( empty( $failed['ids'] ) ) {
889 delete_option( self::FAILED_ORDER_IMPORTS_OPTION );
890 return;
891 }
892
893 update_option( self::FAILED_ORDER_IMPORTS_OPTION, $failed, false );
894 }
895
896 /**
897 * Delete a batch of orders.
898 *
899 * @internal
900 * @param int $batch_size Number of items to delete.
901 * @return void
902 */
903 public static function delete( $batch_size ) {
904 global $wpdb;
905
906 $order_ids = $wpdb->get_col(
907 $wpdb->prepare(
908 "SELECT order_id FROM {$wpdb->prefix}wc_order_stats ORDER BY order_id ASC LIMIT %d",
909 $batch_size
910 )
911 );
912
913 foreach ( $order_ids as $order_id ) {
914 OrdersStatsDataStore::delete_order( $order_id );
915 }
916 }
917
918 /**
919 * Check whether scheduled import is enabled.
920 *
921 * When the "analytics-scheduled-import" feature is disabled, only immediate
922 * import is supported (returns false). When enabled, checks the option value.
923 *
924 * @internal
925 * @since 10.5.0 Introduced as a private method.
926 * @since 11.0.0 Made public.
927 * @return bool
928 */
929 public static function is_scheduled_import_enabled(): bool {
930 if ( ! Features::is_enabled( 'analytics-scheduled-import' ) ) {
931 // If the feature is disabled, only immediate import is supported.
932 return false;
933 }
934
935 $value = get_option( self::SCHEDULED_IMPORT_OPTION, false );
936
937 if ( false !== $value ) {
938 return 'yes' === $value;
939 }
940
941 // Fall back to the legacy option (pre-10.5.0) which used inverted semantics:
942 // 'yes' meant immediate import (= not scheduled), 'no' meant scheduled.
943 $legacy_value = get_option( self::LEGACY_IMMEDIATE_IMPORT_OPTION, false );
944
945 if ( false !== $legacy_value ) {
946 return 'no' === $legacy_value;
947 }
948
949 // Neither option exists — use the default (not scheduled).
950 return false;
951 }
952 }
953