Commit d022dac125d for woocommerce
commit d022dac125dbb48281dc1cb3a27b8787c3d4a81e
Author: Justin P <228780+layoutd@users.noreply.github.com>
Date: Fri Oct 30 10:15:02 2026 +0100
Add a filter to exclude orders from the Analytics import (#69031)
* Add a where-clauses filter to the Analytics order import queries and probe it on import
* Refuse excluded records in the order stats data store update
* Test the Analytics import where-clauses filter across import paths
* Add changelog for the Analytics import where-clauses filter
* Harden the import where-clauses filter: keep '0', escape literal percent signs, ignore failed probes
* Leave the scheduled import cursor unchanged when the filtered cursor query fails
* Tighten the Analytics import exclusion for refunds, held records and failed counts
* Test refund inheritance, held-record syncing and count error logging for the import exclusion
* Shorten the Analytics import filter changelog entry
* Apply the held-record and refund-parent rules inside the import queries
* Test excluded refunds against paging, the scheduled batch and held parents
* Exclude refunds of orders whose import conditions evaluate to NULL
* Test that a NULL import condition on an order also excludes its refunds
* Skip the import and keep the record for retry when the exclusion check fails
* Report a failed exclusion check as a failed stats sync, not a skip
* Set the since tags to 11.4.0 and correct the import filter docblocks
* Keep an order for retry when its stats row is not written, and log failed page queries
* Tidy the import filter tests: shared helpers, data provider, assertion messages
* Skip the page query log when the count query already failed
* Correct the filter docblock and label the import filter test assertions
* Add a changelog entry for retrying orders whose stats fail to save
* Run the full import when the stats write fails, but log it and keep the order for retry
* Note in the filter docblock that refund rows are checked too
* Update the import() docblock and test that other syncs run when the stats write fails
* Stop tracking failed stats writes in import(), which flagged orders an empty stats row skips on purpose
* Apply the import conditions after the cursor range in the scheduled import queries
* Note in the filter docblock that conditions are complete expressions and removals need a historical import
* Test the LIKE condition through the prepared scheduled import query
* Shorten the filter docblock
diff --git a/plugins/woocommerce/changelog/43505-analytics-import-order-where-clauses-filter b/plugins/woocommerce/changelog/43505-analytics-import-order-where-clauses-filter
new file mode 100644
index 00000000000..0e4392d7e46
--- /dev/null
+++ b/plugins/woocommerce/changelog/43505-analytics-import-order-where-clauses-filter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a filter that lets extensions exclude orders, such as marketplace child orders, from Analytics imports.
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
index f5bbe34ae30..6b7850d343a 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
@@ -548,6 +548,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
return -1;
}
+ // Same defense-in-depth for records the import conditions filter excludes. A failed check
+ // writes nothing either, and reports a failed sync rather than a skip.
+ $excluded = OrdersScheduler::is_excluded_from_import( (int) $order->get_id() );
+ if ( null === $excluded ) {
+ return false;
+ }
+ if ( $excluded ) {
+ return -1;
+ }
+
$format = array(
'%d',
'%d',
diff --git a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
index e48fc96a12e..697cbf8a985 100644
--- a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
+++ b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
@@ -244,12 +244,20 @@ class OrdersScheduler extends ImportScheduler {
)";
}
+ $where_clause .= self::get_import_where_clause( $wpdb->posts );
+
$count = $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->posts}
WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
{$where_clause}" // phpcs:ignore unprepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared SQL ok.
);
+ if ( self::log_last_db_error( 'Could not count the orders to import' ) ) {
+ return (object) array(
+ 'total' => 0,
+ 'ids' => array(),
+ );
+ }
$order_ids = absint( $count ) > 0 ? $wpdb->get_col(
$wpdb->prepare(
@@ -264,6 +272,7 @@ class OrdersScheduler extends ImportScheduler {
$offset
)
) : array(); // phpcs:ignore unprepared SQL ok.
+ self::log_last_db_error( 'Could not query orders to import' );
return (object) array(
'total' => absint( $count ),
@@ -301,6 +310,8 @@ class OrdersScheduler extends ImportScheduler {
";
}
+ $where_clause .= self::get_import_where_clause( 'orders' );
+
$count = $wpdb->get_var(
"
SELECT COUNT(*) FROM {$order_table} AS orders
@@ -309,6 +320,12 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
{$where_clause}
"
); // phpcs:ignore unprepared SQL ok.
+ if ( self::log_last_db_error( 'Could not count the orders to import' ) ) {
+ return (object) array(
+ 'total' => 0,
+ 'ids' => array(),
+ );
+ }
$order_ids = absint( $count ) > 0 ? $wpdb->get_col(
$wpdb->prepare(
@@ -323,6 +340,7 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
$offset
)
) : array(); // phpcs:ignore unprepared SQL ok.
+ self::log_last_db_error( 'Could not query orders to import' );
return (object) array(
'total' => absint( $count ),
@@ -365,7 +383,8 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
/**
* Imports a single order or refund to update lookup tables for.
- * An order whose tax lookup rows could not be written stays on the failed imports list.
+ * An order whose tax lookup rows could not be written, or whose exclusion check fails, stays on
+ * the failed imports list.
*
* @internal
* @param int $order_id Order or refund ID.
@@ -400,6 +419,19 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
return;
}
+ $excluded = self::is_excluded_from_import( (int) $order_id );
+ if ( null === $excluded ) {
+ // The exclusion is unknown, so neither import the record nor drop it: keep it on the list
+ // Analytics settings retries.
+ self::record_failed_order_import( $order_id );
+ return;
+ }
+ if ( $excluded ) {
+ // The record is excluded, so drop it from the list Analytics settings retries.
+ self::clear_failed_order_import( $order_id );
+ return;
+ }
+
// Skip trashed records Analytics does not already hold: importing one creates a
// wc_customer_lookup row via Order::get_report_customer_id() that nothing removes
// while the order stays trashed. Records trashed after being imported keep their
@@ -655,6 +687,12 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
// Get orders updated since the cursor position.
$orders = self::get_orders_since( $cursor_date, $cursor_id, $batch_size );
+ // A failed query (a broken import conditions filter, say) also returns an empty result.
+ // Leave the cursor where it is so the batch is retried once the cause is fixed.
+ if ( self::log_last_db_error( 'Could not query orders to import, leaving the cursor unchanged' ) ) {
+ return;
+ }
+
if ( empty( $orders ) ) {
$logger->info( 'No orders to process', $context );
// Update the cursor position to the start time of the batch so that the next batch will start from that point.
@@ -778,18 +816,20 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
private static function get_orders_since_from_orders_table( $cursor_date, $cursor_id, $limit ) {
global $wpdb;
$orders_table = OrdersTableDataStore::get_orders_table_name();
+ $import_where = self::get_import_where_clause( 'orders' );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->get_results(
$wpdb->prepare(
"SELECT id, date_updated_gmt
- FROM {$orders_table}
+ FROM {$orders_table} AS orders
WHERE type IN ('shop_order', 'shop_order_refund')
AND status NOT IN ('wc-auto-draft', 'auto-draft')
AND (
date_updated_gmt > %s
OR (date_updated_gmt = %s AND id > %d)
)
+ {$import_where}
ORDER BY date_updated_gmt ASC, id ASC
LIMIT %d",
$cursor_date,
@@ -815,7 +855,9 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
*/
private static function get_orders_since_from_posts_table( $cursor_date, $cursor_id, $limit ) {
global $wpdb;
+ $import_where = self::get_import_where_clause( $wpdb->posts );
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->get_results(
$wpdb->prepare(
"SELECT ID as id, post_modified_gmt as date_updated_gmt
@@ -826,6 +868,7 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
post_modified_gmt > %s
OR (post_modified_gmt = %s AND ID > %d)
)
+ {$import_where}
ORDER BY post_modified_gmt ASC, ID ASC
LIMIT %d",
$cursor_date,
@@ -834,6 +877,7 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
$limit
)
);
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
@@ -878,6 +922,153 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
return apply_filters( 'woocommerce_analytics_is_test_order', $is_test, $check_order );
}
+ /**
+ * Build the WHERE fragment that applies the import conditions filter to an Analytics order import query.
+ *
+ * A record is kept when Analytics already holds it, so it keeps syncing, or when it passes the
+ * conditions and, for a refund, its parent is not an excluded order Analytics does not hold.
+ * Every import query shares this fragment, so what is counted is what gets imported.
+ *
+ * @param string $table_alias Table name or alias the query uses for the orders or posts table.
+ * @return string Empty string while the filter adds nothing, or a condition starting with ' AND '.
+ */
+ private static function get_import_where_clause( string $table_alias ): string {
+ global $wpdb;
+
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $table_name = OrdersTableDataStore::get_orders_table_name();
+ $columns = array( 'id', 'type', 'parent_order_id' );
+ } else {
+ $table_name = $wpdb->posts;
+ $columns = array( 'ID', 'post_type', 'post_parent' );
+ }
+ list( $id, $type, $parent ) = $columns;
+
+ $conditions = self::get_import_conditions( $table_alias, $table_name );
+ if ( '' === $conditions ) {
+ return '';
+ }
+
+ $parent_conditions = self::get_import_conditions( 'parent_order', $table_name );
+ $stats_table = OrdersStatsDataStore::get_db_table_name();
+
+ // Some callers interpolate this fragment into $wpdb->prepare(), so a literal percent sign
+ // (a LIKE pattern, say) must not read as a placeholder. $wpdb->query() restores it.
+ // A parent whose conditions come out NULL counts as excluded, as it does in the per-record probe.
+ return $wpdb->add_placeholder_escape(
+ " AND (
+ EXISTS ( SELECT 1 FROM {$stats_table} AS held WHERE held.order_id = {$table_alias}.{$id} )
+ OR (
+ ( {$conditions} )
+ AND (
+ {$table_alias}.{$type} <> 'shop_order_refund'
+ OR NOT EXISTS (
+ SELECT 1 FROM {$table_name} AS parent_order
+ WHERE parent_order.{$id} = {$table_alias}.{$parent}
+ AND NOT COALESCE( ( " . ( '' === $parent_conditions ? '1' : $parent_conditions ) . " ), 0 )
+ AND NOT EXISTS ( SELECT 1 FROM {$stats_table} AS parent_held WHERE parent_held.order_id = parent_order.{$id} )
+ )
+ )
+ )
+ )"
+ );
+ }
+
+ /**
+ * Collect the conditions extensions add to the Analytics order import, for one table alias.
+ *
+ * @param string $table_alias Alias to prefix column names with.
+ * @param string $table_name Table the alias stands for.
+ * @return string Empty string, or the conditions joined with AND.
+ */
+ private static function get_import_conditions( string $table_alias, string $table_name ): string {
+ /**
+ * Filters the SQL conditions that decide which orders and refunds Analytics imports.
+ *
+ * Each condition is a complete raw SQL expression: bind values with $wpdb->prepare() and prefix columns with
+ * $table_alias. Refund rows are checked too, so guard order-only conditions with the type column. The filter
+ * runs again with the alias `parent_order`, because a refund inherits the exclusion of its parent order.
+ * After changing the conditions, delete and re-import historical data to apply them to past records.
+ *
+ * @since 11.4.0
+ *
+ * @param string[] $clauses SQL conditions, joined with AND. Empty by default.
+ * @param string $table_alias Alias to prefix column names with.
+ * @param string $table_name The orders table under HPOS, otherwise the posts table.
+ */
+ $clauses = apply_filters( 'woocommerce_analytics_orders_import_where_clauses', array(), $table_alias, $table_name );
+ $clauses = array_filter(
+ array_map( 'trim', array_filter( is_array( $clauses ) ? $clauses : array(), 'is_string' ) ),
+ static function ( $clause ) {
+ // Keep '0': it is a valid condition that excludes every record.
+ return '' !== $clause;
+ }
+ );
+
+ return $clauses ? '( ' . implode( ' ) AND ( ', $clauses ) . ' )' : '';
+ }
+
+ /**
+ * Whether the `woocommerce_analytics_orders_import_where_clauses` filter leaves a record out of the import.
+ *
+ * Applies the same rules as the import queries: a record Analytics already holds is never excluded,
+ * and a refund inherits the exclusion of a parent order Analytics does not hold.
+ *
+ * @internal
+ *
+ * @since 11.4.0
+ *
+ * @param int $order_id Order or refund ID.
+ * @return bool|null Whether the record is excluded, or null when the check failed (the error is logged).
+ */
+ public static function is_excluded_from_import( int $order_id ): ?bool {
+ global $wpdb;
+
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $from = OrdersTableDataStore::get_orders_table_name() . ' AS orders';
+ $table_alias = 'orders';
+ $id_column = 'orders.id';
+ } else {
+ $from = $wpdb->posts;
+ $table_alias = $wpdb->posts;
+ $id_column = "{$wpdb->posts}.ID";
+ }
+
+ $where = self::get_import_where_clause( $table_alias );
+ if ( '' === $where ) {
+ return false;
+ }
+
+ // Evaluate the rules on the record's own row: 1 keeps it, 0 excludes it, and no row means
+ // the record is not in this table, so the conditions cannot exclude it.
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names and filtered SQL conditions are interpolated; the ID is prepared.
+ $kept = $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE( ( 1 {$where} ), 0 ) FROM {$from} WHERE {$id_column} = %d LIMIT 1", $order_id ) );
+
+ if ( self::log_last_db_error( "Could not check whether record #{$order_id} is excluded from the import" ) ) {
+ return null;
+ }
+
+ return null !== $kept && ! (int) $kept;
+ }
+
+ /**
+ * Log the last database error, if there is one, to the import log.
+ *
+ * @param string $message What failed; the database error is appended.
+ * @return bool Whether an error was logged.
+ */
+ private static function log_last_db_error( string $message ): bool {
+ global $wpdb;
+
+ if ( '' === $wpdb->last_error ) {
+ return false;
+ }
+
+ wc_get_logger()->error( "{$message}: {$wpdb->last_error}", array( 'source' => 'wc-analytics-order-import' ) );
+
+ return true;
+ }
+
/**
* Get the recorded failed order imports.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
index 797c82ca3d8..9059ffbb4bd 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
@@ -6,6 +6,9 @@ namespace Automattic\WooCommerce\Tests\Internal\Admin\Schedulers;
use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore as TaxesDataStore;
+use Automattic\WooCommerce\Enums\OrderInternalStatus;
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Unit_Test_Case;
@@ -15,6 +18,7 @@ use WC_Unit_Test_Case;
* @class OrdersSchedulerTest
*/
class OrdersSchedulerTest extends WC_Unit_Test_Case {
+ use LoggerSpyTrait;
/**
* Set up the test environment.
@@ -44,6 +48,421 @@ class OrdersSchedulerTest extends WC_Unit_Test_Case {
delete_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION );
}
+ /**
+ * Hook the import filter that keeps child orders out of Analytics, for the active order storage.
+ */
+ private function exclude_child_orders_from_import(): void {
+ add_filter(
+ 'woocommerce_analytics_orders_import_where_clauses',
+ function ( $clauses, $table_alias ) {
+ $clauses[] = OrderUtil::custom_orders_table_usage_is_enabled()
+ ? "NOT ( {$table_alias}.type = 'shop_order' AND {$table_alias}.parent_order_id <> 0 )"
+ : "NOT ( {$table_alias}.post_type = 'shop_order' AND {$table_alias}.post_parent <> 0 )";
+ return $clauses;
+ },
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * Hook the import filter to add one fixed condition.
+ *
+ * @param string $condition SQL condition.
+ * @return callable The filter callback, for tests that remove it before a retry.
+ */
+ private function add_import_condition( string $condition ): callable {
+ $callback = function ( $clauses ) use ( $condition ) {
+ $clauses[] = $condition;
+ return $clauses;
+ };
+ add_filter( 'woocommerce_analytics_orders_import_where_clauses', $callback );
+
+ return $callback;
+ }
+
+ /**
+ * Refund 1 of an order.
+ *
+ * @param int $order_id Order ID.
+ * @return int Refund ID.
+ */
+ private function create_refund( int $order_id ): int {
+ return wc_create_refund(
+ array(
+ 'order_id' => $order_id,
+ 'amount' => 1,
+ )
+ )->get_id();
+ }
+
+ /**
+ * Create a completed parent order, a completed child order pointing at it, and a refund of the parent.
+ *
+ * @return int[] Parent, child and refund IDs.
+ */
+ private function create_parent_child_and_refund(): array {
+ $parent = \WC_Helper_Order::create_order();
+ $parent->set_status( OrderStatus::COMPLETED );
+ $parent->save();
+
+ $child = \WC_Helper_Order::create_order();
+ $child->set_parent_id( $parent->get_id() );
+ $child->set_status( OrderStatus::COMPLETED );
+ $child->save();
+
+ return array( $parent->get_id(), $child->get_id(), $this->create_refund( $parent->get_id() ) );
+ }
+
+ /**
+ * Order and refund IDs currently in the order stats table.
+ *
+ * @return int[]
+ */
+ private function get_imported_order_ids(): array {
+ global $wpdb;
+
+ return array_map( 'intval', $wpdb->get_col( "SELECT order_id FROM {$wpdb->prefix}wc_order_stats" ) );
+ }
+
+ /**
+ * Status recorded for a record in the order stats table.
+ *
+ * @param int $order_id Order or refund ID.
+ * @return string|null
+ */
+ private function get_imported_status( int $order_id ): ?string {
+ global $wpdb;
+
+ return $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order_id ) );
+ }
+
+ /**
+ * @testdox A refund inherits the exclusion of its parent order in every import path.
+ */
+ public function test_refund_of_excluded_record_is_not_imported(): void {
+ list( $parent_id, $child_id, $refund_id ) = $this->create_parent_child_and_refund();
+ $child_refund_id = $this->create_refund( $child_id );
+ $this->exclude_child_orders_from_import();
+
+ $items = OrdersScheduler::get_items( 100, 1, false, true );
+ $ids = array_map( 'intval', $items->ids );
+ $this->assertSame( $items->total, count( $ids ), 'The count and the page must agree.' );
+ $this->assertNotContains( $child_refund_id, $ids, 'get_items() must leave out the refund of an excluded order.' );
+ $this->assertContains( $refund_id, $ids, 'A refund of a kept order is still imported.' );
+ $this->assertTrue( OrdersScheduler::is_excluded_from_import( $child_refund_id ), 'The per-record check must exclude the refund too.' );
+
+ OrdersScheduler::import( $child_refund_id );
+ $this->assertSame( -1, OrdersStatsDataStore::update( wc_get_order( $child_refund_id ) ), 'The stats data store must skip the refund.' );
+ OrdersScheduler::process_pending_batch( '2000-01-01 00:00:00', 0 );
+
+ $imported = $this->get_imported_order_ids();
+ $this->assertNotContains( $child_refund_id, $imported, 'No import path may write the refund of an excluded order.' );
+ $this->assertNotContains( $child_id, $imported, 'The excluded order stays out.' );
+ $this->assertContains( $parent_id, $imported, 'The scheduled batch imports the kept order.' );
+ $this->assertContains( $refund_id, $imported, 'The scheduled batch imports the refund of the kept order.' );
+ }
+
+ /**
+ * @testdox A refund is excluded along with an order whose conditions come out NULL.
+ */
+ public function test_refund_of_order_with_null_condition_is_excluded(): void {
+ $order = \WC_Helper_Order::create_order();
+ $refund_id = $this->create_refund( $order->get_id() );
+ add_filter(
+ 'woocommerce_analytics_orders_import_where_clauses',
+ function ( $clauses, $table_alias ) {
+ // NULL for orders and true for refunds, like a comparison against an empty nullable column.
+ $column = OrderUtil::custom_orders_table_usage_is_enabled() ? 'type' : 'post_type';
+ $clauses[] = "IF( {$table_alias}.{$column} = 'shop_order', NULL, 1 )";
+ return $clauses;
+ },
+ 10,
+ 2
+ );
+
+ $this->assertTrue( OrdersScheduler::is_excluded_from_import( $order->get_id() ), 'A NULL condition excludes the order.' );
+ $this->assertTrue( OrdersScheduler::is_excluded_from_import( $refund_id ), 'The refund inherits the exclusion of its order.' );
+ $this->assertNotContains( $refund_id, array_map( 'intval', OrdersScheduler::get_items( 100, 1, false, true )->ids ), 'get_items() agrees with the per-record check.' );
+ }
+
+ /**
+ * @testdox A "skip existing" import that pages one record at a time finishes with excluded refunds present.
+ */
+ public function test_skip_existing_import_finishes_with_excluded_refunds(): void {
+ list( , $child_id ) = $this->create_parent_child_and_refund();
+ $this->create_refund( $child_id );
+ $this->exclude_child_orders_from_import();
+
+ // ImportScheduler::import_batch() always reads page 1 while skipping existing records, so a
+ // counted record that never lands would be served again and again.
+ $rounds = 0;
+ do {
+ $items = OrdersScheduler::get_items( 1, 1, false, true );
+ foreach ( $items->ids as $id ) {
+ OrdersScheduler::import( (int) $id );
+ }
+ ++$rounds;
+ } while ( $items->ids && $rounds < 10 );
+
+ $this->assertSame( 0, OrdersScheduler::get_items( 1, 1, false, true )->total, 'Every counted record was imported.' );
+ }
+
+ /**
+ * @testdox A record imported before a condition excluded it keeps syncing through the scheduled batch.
+ */
+ public function test_record_imported_before_exclusion_keeps_syncing(): void {
+ list( , $child_id ) = $this->create_parent_child_and_refund();
+ OrdersScheduler::import( $child_id );
+ $this->assertSame( OrderInternalStatus::COMPLETED, $this->get_imported_status( $child_id ), 'The child is imported before the condition exists.' );
+ $this->exclude_child_orders_from_import();
+
+ $child = wc_get_order( $child_id );
+ $child->set_status( OrderStatus::CANCELLED );
+ $child->save();
+ OrdersScheduler::process_pending_batch( '2000-01-01 00:00:00', 0 );
+
+ $this->assertFalse( OrdersScheduler::is_excluded_from_import( $child_id ), 'A record Analytics holds is never excluded.' );
+ $this->assertSame( OrderInternalStatus::CANCELLED, $this->get_imported_status( $child_id ), 'The status change reaches the held stats row.' );
+ }
+
+ /**
+ * @testdox A new refund of an excluded order that Analytics holds is imported, so its sale is not overstated.
+ */
+ public function test_new_refund_of_held_excluded_record_is_imported(): void {
+ list( , $child_id ) = $this->create_parent_child_and_refund();
+ OrdersScheduler::import( $child_id );
+ $this->exclude_child_orders_from_import();
+
+ $child_refund_id = $this->create_refund( $child_id );
+
+ $this->assertFalse( OrdersScheduler::is_excluded_from_import( $child_refund_id ), 'A refund of a held order is not excluded.' );
+ $this->assertContains( $child_refund_id, array_map( 'intval', OrdersScheduler::get_items( 100, 1, false, true )->ids ), 'get_items() counts it.' );
+
+ OrdersScheduler::import( $child_refund_id );
+
+ $this->assertContains( $child_refund_id, $this->get_imported_order_ids(), 'import() writes the new refund of the held order.' );
+ }
+
+ /**
+ * @testdox A record with no row in the orders table is not excluded.
+ */
+ public function test_is_excluded_from_import_ignores_missing_record(): void {
+ $this->add_import_condition( '0' );
+
+ $this->assertFalse( OrdersScheduler::is_excluded_from_import( 999999999 ), 'With no row to evaluate, the conditions cannot exclude the record.' );
+ }
+
+ /**
+ * @testdox get_items() logs the error when the import conditions make the count query fail.
+ */
+ public function test_get_items_logs_failed_count_query(): void {
+ global $wpdb;
+ $this->create_parent_child_and_refund();
+ $this->add_import_condition( 'no_such_column_for_this_test = 1' );
+
+ $suppress = $wpdb->suppress_errors();
+ $items = OrdersScheduler::get_items( 100, 1, false, true );
+ $wpdb->suppress_errors( $suppress );
+
+ $this->assertSame( 0, $items->total, 'A failed count reads as nothing to import.' );
+ $this->assertSame( array(), $items->ids );
+ $this->assertLogged( 'error', 'Could not count the orders to import' );
+ $this->assertCount( 1, $this->get_logs_by_level( 'error' ), 'Only the failed count is logged, not the page query it skipped.' );
+ }
+
+ /**
+ * @testdox get_items() applies the import conditions filter to both the count and the IDs.
+ */
+ public function test_get_items_honors_import_where_clauses_filter(): void {
+ list( $parent_id, $child_id, $refund_id ) = $this->create_parent_child_and_refund();
+
+ $unfiltered = OrdersScheduler::get_items( 100, 1, false, true );
+ $this->exclude_child_orders_from_import();
+ $filtered = OrdersScheduler::get_items( 100, 1, false, true );
+
+ $unfiltered_ids = array_map( 'intval', $unfiltered->ids );
+ $filtered_ids = array_map( 'intval', $filtered->ids );
+
+ $this->assertContains( $child_id, $unfiltered_ids, 'Without the filter the child order is imported as before.' );
+ $this->assertSame( $unfiltered->total - 1, $filtered->total, 'The count drops by the excluded child order.' );
+ $this->assertNotContains( $child_id, $filtered_ids );
+ $this->assertContains( $parent_id, $filtered_ids );
+ $this->assertContains( $refund_id, $filtered_ids, 'A refund keeps its order in the parent column and must survive a child-order condition.' );
+ }
+
+ /**
+ * @testdox import() skips a record the import conditions filter excludes and imports the rest.
+ */
+ public function test_import_skips_record_excluded_by_import_where_clauses_filter(): void {
+ list( $parent_id, $child_id, $refund_id ) = $this->create_parent_child_and_refund();
+ $this->exclude_child_orders_from_import();
+
+ OrdersScheduler::import( $child_id );
+ OrdersScheduler::import( $parent_id );
+
+ $imported = $this->get_imported_order_ids();
+ $this->assertNotContains( $child_id, $imported, 'The excluded child order is not imported.' );
+ $this->assertContains( $parent_id, $imported, 'The kept parent order is imported.' );
+ $this->assertContains( $refund_id, $imported, 'Importing the parent also syncs its refund.' );
+ }
+
+ /**
+ * @testdox The scheduled import batch skips a record the import conditions filter excludes.
+ */
+ public function test_process_pending_batch_skips_excluded_record(): void {
+ list( $parent_id, $child_id ) = $this->create_parent_child_and_refund();
+ $this->exclude_child_orders_from_import();
+
+ OrdersScheduler::process_pending_batch( '2000-01-01 00:00:00', 0 );
+
+ $imported = $this->get_imported_order_ids();
+ $this->assertContains( $parent_id, $imported );
+ $this->assertNotContains( $child_id, $imported );
+ }
+
+ /**
+ * @testdox The stats data store refuses a record the import conditions filter excludes.
+ */
+ public function test_stats_data_store_update_skips_excluded_record(): void {
+ list( , $child_id ) = $this->create_parent_child_and_refund();
+ $this->exclude_child_orders_from_import();
+
+ $result = OrdersStatsDataStore::update( wc_get_order( $child_id ) );
+
+ $this->assertSame( -1, $result, 'update() reports an excluded record as skipped (-1), not failed.' );
+ $this->assertNotContains( $child_id, $this->get_imported_order_ids() );
+ }
+
+ /**
+ * @testdox An excluded record is dropped from the failed imports list instead of being retried forever.
+ */
+ public function test_import_clears_failed_entry_for_excluded_record(): void {
+ list( , $child_id ) = $this->create_parent_child_and_refund();
+ OrdersScheduler::record_failed_order_import( $child_id );
+ $this->exclude_child_orders_from_import();
+
+ OrdersScheduler::import( $child_id );
+
+ $this->assertNotContains( $child_id, OrdersScheduler::get_failed_order_imports()['ids'] );
+ }
+
+ /**
+ * @testdox A filter return that is not an array of strings is ignored.
+ * @testWith [false]
+ * [[42, " ", null]]
+ *
+ * @param mixed $invalid_return Value the filter returns.
+ */
+ public function test_import_where_clauses_filter_ignores_invalid_return( $invalid_return ): void {
+ list( , $child_id ) = $this->create_parent_child_and_refund();
+ add_filter(
+ 'woocommerce_analytics_orders_import_where_clauses',
+ function () use ( $invalid_return ) {
+ return $invalid_return;
+ }
+ );
+
+ $this->assertContains( $child_id, array_map( 'intval', OrdersScheduler::get_items( 100, 1, false, true )->ids ), 'get_items() ignores the return.' );
+ $this->assertFalse( OrdersScheduler::is_excluded_from_import( $child_id ), 'The per-record check ignores the return.' );
+ }
+
+ /**
+ * @testdox A '0' condition excludes every record instead of being dropped as empty.
+ */
+ public function test_import_where_clauses_filter_keeps_zero_condition(): void {
+ list( $parent_id ) = $this->create_parent_child_and_refund();
+ $this->add_import_condition( '0' );
+
+ $items = OrdersScheduler::get_items( 100, 1, false, true );
+
+ $this->assertSame( 0, $items->total, 'get_items() must count nothing.' );
+ $this->assertSame( array(), $items->ids );
+ $this->assertTrue( OrdersScheduler::is_excluded_from_import( $parent_id ), 'The per-record check must exclude too.' );
+ }
+
+ /**
+ * @testdox A condition with a literal percent sign, such as a LIKE pattern, survives the prepared queries.
+ */
+ public function test_import_where_clauses_filter_allows_like_patterns(): void {
+ list( $parent_id, $child_id, $refund_id ) = $this->create_parent_child_and_refund();
+ add_filter(
+ 'woocommerce_analytics_orders_import_where_clauses',
+ function ( $clauses, $table_alias ) {
+ $column = OrderUtil::custom_orders_table_usage_is_enabled() ? 'type' : 'post_type';
+ $clauses[] = "{$table_alias}.{$column} NOT LIKE '%shop_order_refund'"; // Starts with %s, which prepare() would read as a placeholder.
+ return $clauses;
+ },
+ 10,
+ 2
+ );
+
+ $items = OrdersScheduler::get_items( 100, 1, false, true );
+
+ $ids = array_map( 'intval', $items->ids );
+ $this->assertSame( $items->total, count( $ids ), 'The count and the page must agree.' );
+ $this->assertContains( $parent_id, $ids );
+ $this->assertContains( $child_id, $ids );
+ $this->assertNotContains( $refund_id, $ids, 'The LIKE condition excludes refunds in the prepared page query.' );
+ $this->assertTrue( OrdersScheduler::is_excluded_from_import( $refund_id ), 'The LIKE condition excludes refunds in the prepared probe.' );
+ $this->assertFalse( OrdersScheduler::is_excluded_from_import( $parent_id ) );
+
+ OrdersScheduler::process_pending_batch( '2000-01-01 00:00:00', 0 );
+ $imported = $this->get_imported_order_ids();
+ $this->assertContains( $parent_id, $imported, 'The prepared cursor query keeps the parent.' );
+ $this->assertNotContains( $refund_id, $imported, 'The prepared cursor query applies the LIKE condition.' );
+ }
+
+ /**
+ * @testdox A failing exclusion check logs the error, imports nothing and keeps the record on the failed list.
+ */
+ public function test_failed_exclusion_check_skips_import_and_keeps_record_for_retry(): void {
+ global $wpdb;
+ list( , $child_id ) = $this->create_parent_child_and_refund();
+ $callback = $this->add_import_condition( 'no_such_column_for_this_test = 1' );
+
+ $suppress = $wpdb->suppress_errors();
+ $excluded = OrdersScheduler::is_excluded_from_import( $child_id );
+ OrdersScheduler::import( $child_id );
+ $updated = OrdersStatsDataStore::update( wc_get_order( $child_id ) );
+ $wpdb->suppress_errors( $suppress );
+ remove_filter( 'woocommerce_analytics_orders_import_where_clauses', $callback );
+
+ $this->assertNull( $excluded, 'A failed check is neither an exclusion nor a pass.' );
+ $this->assertLogged( 'error', "Could not check whether record #{$child_id} is excluded from the import" );
+ $this->assertFalse( $updated, 'The stats data store reports a failed sync and writes nothing.' );
+ $this->assertNotContains( $child_id, $this->get_imported_order_ids() );
+ $this->assertContains( $child_id, OrdersScheduler::get_failed_order_imports()['ids'], 'The record stays on the list Analytics settings retries.' );
+
+ // Once the condition is fixed, the retry imports the record.
+ OrdersScheduler::import( $child_id );
+ $this->assertContains( $child_id, $this->get_imported_order_ids() );
+ $this->assertNotContains( $child_id, OrdersScheduler::get_failed_order_imports()['ids'] );
+ }
+
+ /**
+ * @testdox The scheduled batch leaves the cursor unchanged when the import conditions make the query fail.
+ */
+ public function test_process_pending_batch_keeps_cursor_when_filtered_query_fails(): void {
+ global $wpdb;
+ list( $parent_id ) = $this->create_parent_child_and_refund();
+ update_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION, '2000-01-01 00:00:00', false );
+ update_option( OrdersScheduler::LAST_PROCESSED_ORDER_ID_OPTION, 0, false );
+ $callback = $this->add_import_condition( 'no_such_column_for_this_test = 1' );
+
+ $suppress = $wpdb->suppress_errors();
+ OrdersScheduler::process_pending_batch();
+ $wpdb->suppress_errors( $suppress );
+ remove_filter( 'woocommerce_analytics_orders_import_where_clauses', $callback );
+
+ $this->assertSame( '2000-01-01 00:00:00', get_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION ), 'The cursor must not move past orders the failed query never returned.' );
+ $this->assertNotContains( $parent_id, $this->get_imported_order_ids() );
+
+ // Once the condition is gone, the same cursor picks the orders up.
+ OrdersScheduler::process_pending_batch();
+ $this->assertContains( $parent_id, $this->get_imported_order_ids() );
+ }
+
/**
* Test that batch processor is scheduled when called.
*/