PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.1
WooCommerce v10.9.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 1 month ago MailchimpScheduler.php 1 year ago OrdersScheduler.php 1 month ago
OrdersScheduler.php
805 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 * Attach order lookup update hooks.
89 *
90 * @internal
91 */
92 public static function init() {
93 // Activate WC_Order extension.
94 \Automattic\WooCommerce\Admin\Overrides\Order::add_filters();
95 \Automattic\WooCommerce\Admin\Overrides\OrderRefund::add_filters();
96
97 if ( self::is_scheduled_import_enabled() ) {
98 // Schedule recurring batch processor.
99 add_action( 'action_scheduler_ensure_recurring_actions', array( __CLASS__, 'schedule_recurring_batch_processor' ) );
100 } else {
101 // Schedule import immediately on order create/update/delete.
102 add_action( 'woocommerce_update_order', array( __CLASS__, 'possibly_schedule_import' ) );
103 add_filter( 'woocommerce_create_order', array( __CLASS__, 'possibly_schedule_import' ) );
104 add_action( 'woocommerce_refund_created', array( __CLASS__, 'possibly_schedule_import' ) );
105 add_action( 'woocommerce_schedule_import', array( __CLASS__, 'possibly_schedule_import' ) );
106 }
107
108 if ( Features::is_enabled( 'analytics-scheduled-import' ) ) {
109 // Watch for changes to the scheduled import option.
110 add_action( 'add_option_' . self::SCHEDULED_IMPORT_OPTION, array( __CLASS__, 'handle_scheduled_import_option_added' ), 10, 2 );
111 add_action( 'update_option_' . self::SCHEDULED_IMPORT_OPTION, array( __CLASS__, 'handle_scheduled_import_option_change' ), 10, 2 );
112 add_action( 'delete_option', array( __CLASS__, 'handle_scheduled_import_option_before_delete' ), 10, 1 );
113 }
114
115 OrdersStatsDataStore::init();
116 CouponsDataStore::init();
117 ProductsDataStore::init();
118 TaxesDataStore::init();
119 OrderDataStore::init();
120
121 parent::init();
122 }
123
124 /**
125 * Add customer dependencies.
126 *
127 * @internal
128 * @return array
129 */
130 public static function get_dependencies() {
131 return array(
132 'import_batch_init' => \Automattic\WooCommerce\Internal\Admin\Schedulers\CustomersScheduler::get_action( 'import_batch_init' ),
133 );
134 }
135
136 /**
137 * Get all available scheduling actions.
138 * Extends parent to add the new batch processor action.
139 *
140 * @internal
141 * @return array
142 */
143 public static function get_scheduler_actions() {
144 return array_merge(
145 parent::get_scheduler_actions(),
146 array(
147 self::PROCESS_PENDING_ORDERS_BATCH_ACTION => 'wc-admin_process_pending_orders_batch',
148 )
149 );
150 }
151
152 /**
153 * Get batch sizes for OrdersScheduler actions.
154 *
155 * @internal
156 * @return array
157 */
158 public static function get_batch_sizes() {
159 return array_merge(
160 parent::get_batch_sizes(),
161 array(
162 self::PROCESS_PENDING_ORDERS_BATCH_ACTION => 100,
163 )
164 );
165 }
166
167 /**
168 * Get the order/refund IDs and total count that need to be synced.
169 *
170 * @internal
171 * @param int $limit Number of records to retrieve.
172 * @param int $page Page number.
173 * @param int|bool $days Number of days prior to current date to limit search results.
174 * @param bool $skip_existing Skip already imported orders.
175 */
176 public static function get_items( $limit = 10, $page = 1, $days = false, $skip_existing = false ) {
177 if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
178 return self::get_items_from_orders_table( $limit, $page, $days, $skip_existing );
179 } else {
180 return self::get_items_from_posts_table( $limit, $page, $days, $skip_existing );
181 }
182 }
183
184 /**
185 * Helper method to ger order/refund IDS and total count that needs to be synced.
186 *
187 * @internal
188 * @param int $limit Number of records to retrieve.
189 * @param int $page Page number.
190 * @param int|bool $days Number of days prior to current date to limit search results.
191 * @param bool $skip_existing Skip already imported orders.
192 *
193 * @return object Total counts.
194 */
195 private static function get_items_from_posts_table( $limit, $page, $days, $skip_existing ) {
196 global $wpdb;
197 $where_clause = '';
198 $offset = $page > 1 ? ( $page - 1 ) * $limit : 0;
199
200 if ( is_int( $days ) ) {
201 $days_ago = gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) );
202 $where_clause .= " AND post_date_gmt >= '{$days_ago}'";
203 }
204
205 if ( $skip_existing ) {
206 $where_clause .= " AND NOT EXISTS (
207 SELECT 1 FROM {$wpdb->prefix}wc_order_stats
208 WHERE {$wpdb->prefix}wc_order_stats.order_id = {$wpdb->posts}.ID
209 )";
210 }
211
212 $count = $wpdb->get_var(
213 "SELECT COUNT(*) FROM {$wpdb->posts}
214 WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
215 AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
216 {$where_clause}" // phpcs:ignore unprepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared SQL ok.
217 );
218
219 $order_ids = absint( $count ) > 0 ? $wpdb->get_col(
220 $wpdb->prepare(
221 "SELECT ID FROM {$wpdb->posts}
222 WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
223 AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
224 {$where_clause}
225 ORDER BY post_date_gmt ASC
226 LIMIT %d
227 OFFSET %d",
228 $limit,
229 $offset
230 )
231 ) : array(); // phpcs:ignore unprepared SQL ok.
232
233 return (object) array(
234 'total' => absint( $count ),
235 'ids' => $order_ids,
236 );
237 }
238
239 /**
240 * Helper method to ger order/refund IDS and total count that needs to be synced from HPOS.
241 *
242 * @internal
243 * @param int $limit Number of records to retrieve.
244 * @param int $page Page number.
245 * @param int|bool $days Number of days prior to current date to limit search results.
246 * @param bool $skip_existing Skip already imported orders.
247 *
248 * @return object Total counts.
249 */
250 private static function get_items_from_orders_table( $limit, $page, $days, $skip_existing ) {
251 global $wpdb;
252 $where_clause = '';
253 $offset = $page > 1 ? ( $page - 1 ) * $limit : 0;
254 $order_table = OrdersTableDataStore::get_orders_table_name();
255
256 if ( is_int( $days ) ) {
257 $days_ago = gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) );
258 $where_clause .= " AND orders.date_created_gmt >= '{$days_ago}'";
259 }
260
261 if ( $skip_existing ) {
262 $where_clause .= "AND NOT EXiSTS (
263 SELECT 1 FROM {$wpdb->prefix}wc_order_stats
264 WHERE {$wpdb->prefix}wc_order_stats.order_id = orders.id
265 )
266 ";
267 }
268
269 $count = $wpdb->get_var(
270 "
271 SELECT COUNT(*) FROM {$order_table} AS orders
272 WHERE type in ( 'shop_order', 'shop_order_refund' )
273 AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
274 {$where_clause}
275 "
276 ); // phpcs:ignore unprepared SQL ok.
277
278 $order_ids = absint( $count ) > 0 ? $wpdb->get_col(
279 $wpdb->prepare(
280 "SELECT id FROM {$order_table} AS orders
281 WHERE type IN ( 'shop_order', 'shop_order_refund' )
282 AND status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
283 {$where_clause}
284 ORDER BY date_created_gmt ASC
285 LIMIT %d
286 OFFSET %d",
287 $limit,
288 $offset
289 )
290 ) : array(); // phpcs:ignore unprepared SQL ok.
291
292 return (object) array(
293 'total' => absint( $count ),
294 'ids' => $order_ids,
295 );
296 }
297
298 /**
299 * Get total number of rows imported.
300 *
301 * @internal
302 */
303 public static function get_total_imported() {
304 global $wpdb;
305 return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats" );
306 }
307
308 /**
309 * Schedule this import if the post is an order or refund.
310 * Note: This method is only called when scheduled import is disabled
311 * (immediate mode). Otherwise, orders are processed in batches periodically.
312 *
313 * @param int $order_id Post ID.
314 *
315 * @internal
316 * @returns int The order id
317 */
318 public static function possibly_schedule_import( $order_id ) {
319 if ( self::is_scheduled_import_enabled() ) {
320 return $order_id;
321 }
322
323 if ( ! OrderUtil::is_order( $order_id, array( 'shop_order' ) ) && 'woocommerce_refund_created' !== current_filter() && 'woocommerce_schedule_import' !== current_filter() ) {
324 return $order_id;
325 }
326
327 self::schedule_action( 'import', array( $order_id ) );
328 return $order_id;
329 }
330
331 /**
332 * Imports a single order or refund to update lookup tables for.
333 * If an error is encountered in one of the updates, a retry action is scheduled.
334 *
335 * @internal
336 * @param int $order_id Order or refund ID.
337 * @return void
338 */
339 public static function import( $order_id ) {
340 $order = wc_get_order( $order_id );
341
342 // If the order isn't found for some reason, skip the sync.
343 if ( ! $order ) {
344 return;
345 }
346
347 $type = $order->get_type();
348
349 // If the order isn't the right type, skip sync.
350 if ( 'shop_order' !== $type && 'shop_order_refund' !== $type ) {
351 return;
352 }
353
354 // If the order has no id or date created, skip sync.
355 if ( ! $order->get_id() || ! $order->get_date_created() ) {
356 return;
357 }
358
359 // Skip test orders (e.g., WCPay test mode) from analytics.
360 if ( self::is_test_order( $order ) ) {
361 wc_get_logger()->debug(
362 sprintf( 'Skipping test order #%d from analytics import.', $order_id ),
363 array( 'source' => 'wc-analytics-order-import' )
364 );
365 return;
366 }
367
368 $results = array(
369 OrdersStatsDataStore::sync_order( $order_id ),
370 ProductsDataStore::sync_order_products( $order_id ),
371 CouponsDataStore::sync_order_coupons( $order_id ),
372 TaxesDataStore::sync_order_taxes( $order_id ),
373 CustomersDataStore::sync_order_customer( $order_id ),
374 );
375
376 if ( 'shop_order' === $type ) {
377 $order_refunds = $order->get_refunds();
378
379 foreach ( $order_refunds as $refund ) {
380 OrdersStatsDataStore::sync_order( $refund->get_id() );
381 }
382 }
383
384 ReportsCache::invalidate();
385
386 /**
387 * Fires after an order or refund has been imported into Analytics lookup tables
388 * and the reports cache has been invalidated.
389 *
390 * @since 10.3.0
391 * @param int $order_id Order or refund ID.
392 */
393 do_action( 'woocommerce_order_scheduler_after_import_order', $order_id );
394 }
395
396 /**
397 * Schedule recurring batch processor for order imports.
398 *
399 * @internal
400 */
401 public static function schedule_recurring_batch_processor() {
402 $action_hook = self::get_action( self::PROCESS_PENDING_ORDERS_BATCH_ACTION );
403 if ( null === $action_hook ) {
404 return;
405 }
406 // The most efficient way to check for an existing action is to use `as_has_scheduled_action`, but in unusual
407 // cases where another plugin has loaded a very old version of Action Scheduler, it may not be available to us.
408 $has_scheduled_action = function_exists( 'as_has_scheduled_action' ) ? 'as_has_scheduled_action' : 'as_next_scheduled_action';
409 if ( call_user_func( $has_scheduled_action, $action_hook ) ) {
410 return;
411 }
412
413 $interval = self::get_import_interval();
414
415 as_schedule_recurring_action( time(), $interval, $action_hook, array(), static::$group ?? '', true );
416 }
417
418 /**
419 * Handle changes to the scheduled import option.
420 *
421 * When switching from scheduled to immediate import,
422 * we need to run a final catchup batch to ensure no orders are missed.
423 *
424 * When switching from immediate to scheduled import,
425 * we need to reschedule the recurring batch processor.
426 *
427 * @internal
428 * @param mixed $old_value The old value of the option.
429 * @param mixed $new_value The new value of the option.
430 * @return void
431 */
432 public static function handle_scheduled_import_option_change( $old_value, $new_value ) {
433 // If switching from scheduled to immediate import.
434 if ( 'yes' === $old_value && 'no' === $new_value ) {
435 // Unschedule the recurring batch processor.
436 $action_hook = self::get_action( self::PROCESS_PENDING_ORDERS_BATCH_ACTION );
437 if ( null !== $action_hook ) {
438 as_unschedule_all_actions( $action_hook, array(), static::$group ?? '' );
439 }
440
441 // Schedule an immediate catchup batch to process all orders up to now.
442 // This ensures no orders are missed during the transition.
443 self::schedule_action( self::PROCESS_PENDING_ORDERS_BATCH_ACTION, array( null, null ) );
444 } elseif ( 'no' === $old_value && 'yes' === $new_value ) {
445 // Switching from immediate to scheduled import.
446 // Set the last processed order date to now with 1 minute buffer to ensure no orders are missed.
447 update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, gmdate( 'Y-m-d H:i:s', time() - MINUTE_IN_SECONDS ) );
448 update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0 );
449
450 // Schedule the recurring batch processor.
451 self::schedule_recurring_batch_processor();
452 }
453 }
454
455 /**
456 * Handle addition of the scheduled import option.
457 *
458 * @internal
459 * @param string $option_name The name of the option that was added.
460 * @param string $value The value of the option that was added.
461 *
462 * @return void
463 */
464 public static function handle_scheduled_import_option_added( $option_name, $value ) {
465 if ( self::SCHEDULED_IMPORT_OPTION !== $option_name ) {
466 return;
467 }
468
469 self::handle_scheduled_import_option_change( self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE, $value );
470 }
471
472 /**
473 * Handle deletion of the scheduled import option.
474 *
475 * @internal
476 * @param string $option_name The name of the option that was deleted.
477 *
478 * @return void
479 */
480 public static function handle_scheduled_import_option_before_delete( $option_name ) {
481 if ( self::SCHEDULED_IMPORT_OPTION !== $option_name ) {
482 return;
483 }
484
485 self::handle_scheduled_import_option_change(
486 get_option( self::SCHEDULED_IMPORT_OPTION, self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE ),
487 self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE,
488 );
489 }
490
491 /**
492 * Process pending orders in batch.
493 *
494 * This method queries for orders updated since the last cursor position
495 * (compound cursor: date + ID) and imports them into the analytics tables.
496 *
497 * @internal
498 * @param string|null $cursor_date Cursor date in 'Y-m-d H:i:s' format. Orders after this date will be processed.
499 * @param int|null $cursor_id Cursor order ID. Combined with $cursor_date to form compound cursor.
500 * @return void
501 */
502 public static function process_pending_batch( $cursor_date = null, $cursor_id = null ) {
503 $logger = wc_get_logger();
504 $context = array( 'source' => 'wc-analytics-order-import' );
505
506 if ( self::is_importing() ) {
507 // No need to process if an import is already in progress.
508 $logger->info( 'Import is already in progress, skipping batch import.', $context );
509 return;
510 }
511
512 // Load cursor position from options if not provided.
513 // 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.
514 $default_cursor_date = gmdate( 'Y-m-d H:i:s', strtotime( '-24 hours' ) );
515 $cursor_date = $cursor_date ?? get_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, $default_cursor_date );
516 $cursor_id = $cursor_id ?? (int) get_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0 );
517
518 // Validate cursor date.
519 if ( ! $cursor_date || ! strtotime( $cursor_date ) ) {
520 $logger->error( 'Invalid cursor date: ' . $cursor_date, $context );
521 $cursor_date = $default_cursor_date;
522 }
523
524 $batch_size = self::get_batch_size( self::PROCESS_PENDING_ORDERS_BATCH_ACTION );
525
526 $logger->info(
527 sprintf( 'Starting batch import. Cursor: %s (ID: %d), batch size: %d', $cursor_date, $cursor_id, $batch_size ),
528 $context
529 );
530
531 $start_time = microtime( true );
532
533 // Get orders updated since the cursor position.
534 $orders = self::get_orders_since( $cursor_date, $cursor_id, $batch_size );
535
536 if ( empty( $orders ) ) {
537 $logger->info( 'No orders to process', $context );
538 // Update the cursor position to the start time of the batch so that the next batch will start from that point.
539 update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, gmdate( 'Y-m-d H:i:s', (int) $start_time ), false );
540 update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0, false );
541 return;
542 }
543
544 $orders_count = count( $orders );
545 $processed_count = 0;
546 foreach ( $orders as $order ) {
547 try {
548 self::import( $order->id );
549 ++$processed_count;
550
551 // Advance cursor after each successful import. Since orders are sorted by
552 // date ASC, id ASC, we can simply overwrite with the current order's values.
553 $cursor_date = $order->date_updated_gmt;
554 $cursor_id = $order->id;
555 } catch ( \Throwable $e ) {
556 // Log the failure and advance the cursor past the failing order so that
557 // it is skipped on the next run rather than blocking the entire pipeline.
558 static::log_import_error( $order->id, $e, $context );
559 $cursor_date = $order->date_updated_gmt;
560 $cursor_id = $order->id;
561 }
562 }
563
564 // Save the updated cursor position.
565 update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, $cursor_date, false );
566 update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, $cursor_id, false );
567
568 $elapsed_time = microtime( true ) - $start_time;
569 $logger->info(
570 sprintf(
571 'Batch import completed. Processed: %d/%d orders in %.2f seconds. Cursor: %s (ID: %d)',
572 $processed_count,
573 $orders_count,
574 $elapsed_time,
575 $cursor_date,
576 $cursor_id
577 ),
578 $context
579 );
580
581 // If we fetched a full batch, there might be more orders to process.
582 // Use the fetched count rather than successful count so that skipped
583 // failing orders do not suppress scheduling of the next batch.
584 if ( $orders_count === $batch_size ) {
585 $logger->info( 'Full batch processed, scheduling next batch', $context );
586 self::schedule_action(
587 'process_pending_batch',
588 array( $cursor_date, $cursor_id )
589 );
590 }
591 }
592
593 /**
594 * Get the import interval.
595 *
596 * @internal
597 * @return int The import interval in seconds.
598 */
599 public static function get_import_interval() {
600 /**
601 * Filter the analytics import interval.
602 *
603 * @since 10.4.0
604 * @param int $interval The import interval in seconds. Default is 12 hours.
605 */
606 return apply_filters( 'woocommerce_analytics_import_interval', 12 * HOUR_IN_SECONDS );
607 }
608
609 /**
610 * Get orders updated since the specified cursor position.
611 *
612 * Uses a compound cursor (date + ID) to handle cases where multiple orders
613 * have the same timestamp. This ensures we can paginate through orders reliably
614 * even when batch_size < number of orders at the same timestamp.
615 *
616 * @internal
617 * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format.
618 * @param int $cursor_id Cursor order ID.
619 * @param int $limit Number of orders to retrieve.
620 * @return array Array of objects with 'id' and 'date_updated_gmt' properties.
621 */
622 private static function get_orders_since( $cursor_date, $cursor_id, $limit ) {
623 if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
624 return self::get_orders_since_from_orders_table( $cursor_date, $cursor_id, $limit );
625 } else {
626 return self::get_orders_since_from_posts_table( $cursor_date, $cursor_id, $limit );
627 }
628 }
629
630 /**
631 * Get orders from HPOS orders table updated since the specified cursor position.
632 *
633 * Query logic uses a compound cursor (date, ID) to handle pagination when multiple
634 * orders share the same timestamp:
635 * - WHERE date > cursor_date: Get orders with newer timestamps
636 * - OR (date = cursor_date AND id > cursor_id): Continue processing same timestamp
637 *
638 * Example: With batch_size=100 and 1000 orders at '2024-01-01 10:00:00',
639 * this processes them across 10 batches without infinite loops or duplicates.
640 *
641 * @internal
642 * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format.
643 * @param int $cursor_id Cursor order ID.
644 * @param int $limit Number of orders to retrieve.
645 * @return array Array of objects with 'id' and 'date_updated_gmt' properties.
646 */
647 private static function get_orders_since_from_orders_table( $cursor_date, $cursor_id, $limit ) {
648 global $wpdb;
649 $orders_table = OrdersTableDataStore::get_orders_table_name();
650
651 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
652 return $wpdb->get_results(
653 $wpdb->prepare(
654 "SELECT id, date_updated_gmt
655 FROM {$orders_table}
656 WHERE type IN ('shop_order', 'shop_order_refund')
657 AND status NOT IN ('wc-auto-draft', 'auto-draft', 'trash')
658 AND (
659 date_updated_gmt > %s
660 OR (date_updated_gmt = %s AND id > %d)
661 )
662 ORDER BY date_updated_gmt ASC, id ASC
663 LIMIT %d",
664 $cursor_date,
665 $cursor_date,
666 $cursor_id,
667 $limit
668 )
669 );
670 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
671 }
672
673 /**
674 * Get orders from posts table updated since the specified cursor position.
675 *
676 * Uses the same compound cursor logic as get_orders_since_from_orders_table()
677 * but queries the posts table instead of the HPOS orders table.
678 *
679 * @internal
680 * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format.
681 * @param int $cursor_id Cursor order ID.
682 * @param int $limit Number of orders to retrieve.
683 * @return array Array of objects with 'id' and 'date_updated_gmt' properties.
684 */
685 private static function get_orders_since_from_posts_table( $cursor_date, $cursor_id, $limit ) {
686 global $wpdb;
687
688 return $wpdb->get_results(
689 $wpdb->prepare(
690 "SELECT ID as id, post_modified_gmt as date_updated_gmt
691 FROM {$wpdb->posts}
692 WHERE post_type IN ('shop_order', 'shop_order_refund')
693 AND post_status NOT IN ('wc-auto-draft', 'auto-draft', 'trash')
694 AND (
695 post_modified_gmt > %s
696 OR (post_modified_gmt = %s AND ID > %d)
697 )
698 ORDER BY post_modified_gmt ASC, ID ASC
699 LIMIT %d",
700 $cursor_date,
701 $cursor_date,
702 $cursor_id,
703 $limit
704 )
705 );
706 }
707
708 /**
709 * Check if an order is a test order that should be excluded from analytics.
710 *
711 * For refunds, the parent order is checked instead, since refunds do not
712 * carry the test mode metadata directly.
713 *
714 * @param \WC_Abstract_Order $order Order object.
715 * @return bool
716 *
717 * @since 10.7.0
718 */
719 public static function is_test_order( $order ) {
720 if ( ! $order instanceof \WC_Abstract_Order ) {
721 return false;
722 }
723
724 // For refunds, check the parent order.
725 $check_order = $order;
726 if ( 'shop_order_refund' === $order->get_type() ) {
727 $check_order = wc_get_order( $order->get_parent_id() );
728 if ( ! $check_order instanceof \WC_Abstract_Order ) {
729 return false;
730 }
731 }
732
733 $is_test = 'test' === $check_order->get_meta( '_wcpay_mode' );
734
735 /**
736 * Filter whether an order is a test order excluded from analytics.
737 *
738 * Use this filter to customize test order detection beyond the default
739 * WCPay test mode check, e.g., to exclude orders from other payment
740 * gateways' test/sandbox modes.
741 *
742 * @param bool $is_test Whether the order is a test order.
743 * @param \WC_Abstract_Order $order The order being checked (for refunds, this is the parent order).
744 *
745 * @since 10.7.0
746 */
747 return apply_filters( 'woocommerce_analytics_is_test_order', $is_test, $check_order );
748 }
749
750 /**
751 * Delete a batch of orders.
752 *
753 * @internal
754 * @param int $batch_size Number of items to delete.
755 * @return void
756 */
757 public static function delete( $batch_size ) {
758 global $wpdb;
759
760 $order_ids = $wpdb->get_col(
761 $wpdb->prepare(
762 "SELECT order_id FROM {$wpdb->prefix}wc_order_stats ORDER BY order_id ASC LIMIT %d",
763 $batch_size
764 )
765 );
766
767 foreach ( $order_ids as $order_id ) {
768 OrdersStatsDataStore::delete_order( $order_id );
769 }
770 }
771
772 /**
773 * Check whether scheduled import is enabled.
774 *
775 * When the "analytics-scheduled-import" feature is disabled, only immediate
776 * import is supported (returns false). When enabled, checks the option value.
777 *
778 * @internal
779 * @return bool
780 */
781 private static function is_scheduled_import_enabled(): bool {
782 if ( ! Features::is_enabled( 'analytics-scheduled-import' ) ) {
783 // If the feature is disabled, only immediate import is supported.
784 return false;
785 }
786
787 $value = get_option( self::SCHEDULED_IMPORT_OPTION, false );
788
789 if ( false !== $value ) {
790 return 'yes' === $value;
791 }
792
793 // Fall back to the legacy option (pre-10.5.0) which used inverted semantics:
794 // 'yes' meant immediate import (= not scheduled), 'no' meant scheduled.
795 $legacy_value = get_option( self::LEGACY_IMMEDIATE_IMPORT_OPTION, false );
796
797 if ( false !== $legacy_value ) {
798 return 'no' === $legacy_value;
799 }
800
801 // Neither option exists — use the default (not scheduled).
802 return false;
803 }
804 }
805