Commit c6aceebb99f for woocommerce
commit c6aceebb99fa95e44c73e602eb20505884c2e0e7
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date: Fri Sep 11 11:41:49 2026 +0300
Fix Stock report export not completing when products are in draft (#68045)
diff --git a/plugins/woocommerce/changelog/fix-40831-stock-report-export-post-status b/plugins/woocommerce/changelog/fix-40831-stock-report-export-post-status
new file mode 100644
index 00000000000..dbe991435c4
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-40831-stock-report-export-post-status
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Analytics: pin the Stock report to published and private products so its CSV export always completes and its download email is sent.
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Stock/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Stock/Controller.php
index 0fd3fbec377..a8b8fd167e0 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Stock/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Stock/Controller.php
@@ -11,6 +11,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\GenericController;
use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
+use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
use WP_REST_Request;
use WP_REST_Response;
@@ -74,6 +75,11 @@ class Controller extends GenericController implements ExportableInterface {
$args['post_type'] = array( 'product', 'product_variation' );
+ // Set the statuses explicitly. WP_Query's default set also matches draft, pending and future
+ // posts in an admin context, and the CSV export counts its rows once in a REST request and
+ // again in an admin-ajax one, so a context-dependent set leaves the two counts disagreeing.
+ $args['post_status'] = array( ProductStatus::PUBLISH, ProductStatus::PRIVATE );
+
if ( ProductStockStatus::LOW_STOCK === $request['type'] ) {
$args['low_in_stock'] = true;
} elseif ( in_array( $request['type'], array_keys( $this->status_options ), true ) ) {
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/DataStore.php
index 4cf29ab5f03..a4539d08896 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Stock/Stats/DataStore.php
@@ -9,6 +9,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
+use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
/**
@@ -134,10 +135,17 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
* @return int Product count.
*/
private function get_product_count() {
- $query_args = array();
- $query_args['post_type'] = array( 'product', 'product_variation' );
- $query = new \WP_Query();
+ // The statuses are the ones the counts above use. WP_Query's default set is context-dependent,
+ // and this count is cached store-wide for 30 days, so it would otherwise freeze at whatever
+ // the first caller's context produced and disagree with the rest of the report.
+ $query_args = array(
+ 'post_type' => array( 'product', 'product_variation' ),
+ 'post_status' => array( ProductStatus::PUBLISH, ProductStatus::PRIVATE ),
+ );
+
+ $query = new \WP_Query();
$query->query( $query_args );
+
return intval( $query->found_posts );
}
}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-stock-stats.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-stock-stats.php
index b1f9f79212b..7f9dbc9a76d 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-stock-stats.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-stock-stats.php
@@ -32,6 +32,15 @@ class WC_Admin_Tests_API_Reports_Stock_Stats extends WC_REST_Unit_Test_Case {
);
}
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ unset( $GLOBALS['current_screen'] );
+
+ parent::tearDown();
+ }
+
/**
* Test route registration.
*/
@@ -102,6 +111,35 @@ class WC_Admin_Tests_API_Reports_Stock_Stats extends WC_REST_Unit_Test_Case {
$this->assertEquals( 13, $reports['totals'][ ProductStockStatus::IN_STOCK ] );
}
+ /**
+ * The products total is cached store-wide for 30 days, so it must not depend on the context
+ * of whichever request happened to prime it. Left to its default, WP_Query also counts draft,
+ * pending and scheduled products in an admin context.
+ */
+ public function test_get_reports_products_total_ignores_unpublished_products() {
+ wp_set_current_user( $this->user );
+ WC_Helper_Reports::reset_stats_dbs();
+ delete_transient( 'wc_admin_product_count' );
+
+ $this->create_stock_products( 2, ProductStockStatus::IN_STOCK );
+ $this->factory->post->create_many(
+ 3,
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'draft',
+ )
+ );
+
+ set_current_screen( 'edit-post' );
+ $this->assertTrue( is_admin(), 'The report must be requested in an admin context.' );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', $this->endpoint ) );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( 2, $reports['totals']['products'] );
+ }
+
/**
* Create published products with the lookup data consumed by the stock stats queries.
*
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Stock/ControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Stock/ControllerTest.php
new file mode 100644
index 00000000000..624060272ea
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Stock/ControllerTest.php
@@ -0,0 +1,123 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API\Reports\Stock;
+
+use Automattic\WooCommerce\Enums\ProductStatus;
+use WC_Product_Simple;
+use WC_REST_Unit_Test_Case;
+use WP_REST_Request;
+
+/**
+ * Stock report API controller test.
+ */
+class ControllerTest extends WC_REST_Unit_Test_Case {
+ /**
+ * Endpoint.
+ *
+ * @var string
+ */
+ const ENDPOINT = '/wc-analytics/reports/stock';
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ unset( $GLOBALS['current_screen'] );
+
+ parent::tearDown();
+ }
+
+ /**
+ * The CSV export counts the rows once when it queues its batches (a REST request) and again
+ * while each batch runs (an admin-ajax request). A report that returns more products in an
+ * admin context leaves the export short of 100%, so its download email is never sent.
+ *
+ * @testdox Should list only published and private products, in an admin context and outside one.
+ */
+ public function test_report_lists_only_published_and_private_products_in_any_context(): void {
+ $statuses = array(
+ ProductStatus::PUBLISH,
+ ProductStatus::PRIVATE,
+ ProductStatus::DRAFT,
+ ProductStatus::PENDING,
+ ProductStatus::FUTURE,
+ );
+
+ $ids = array();
+ foreach ( $statuses as $status ) {
+ $ids[ $status ] = $this->create_product( $status )->get_id();
+ }
+
+ $listed = array( $ids[ ProductStatus::PUBLISH ], $ids[ ProductStatus::PRIVATE ] );
+ sort( $listed );
+
+ $expected = array(
+ 'ids' => $listed,
+ 'total' => count( $listed ),
+ );
+
+ $this->assertFalse( is_admin(), 'The first report must be requested outside an admin context.' );
+ $this->assertSame( $expected, $this->get_report( $ids ), 'The stock report lists the wrong products outside an admin context.' );
+
+ set_current_screen( 'edit-post' );
+
+ $this->assertTrue( is_admin(), 'The second report must be requested in an admin context.' );
+ $this->assertSame( $expected, $this->get_report( $ids ), 'The stock report lists the wrong products in an admin context.' );
+ }
+
+ /**
+ * Create a product with the given status.
+ *
+ * @param string $status Product status.
+ * @return WC_Product_Simple
+ */
+ private function create_product( string $status ): WC_Product_Simple {
+ $product = new WC_Product_Simple();
+ $product->set_name( "Test $status product" );
+ $product->set_regular_price( 5 );
+ $product->set_manage_stock( true );
+ $product->set_stock_quantity( 1 );
+
+ if ( ProductStatus::FUTURE === $status ) {
+ $product->set_date_created( time() + WEEK_IN_SECONDS );
+ }
+
+ $product->set_status( $status );
+ $product->save();
+
+ return $product;
+ }
+
+ /**
+ * Request the stock report for the given products.
+ *
+ * @param int[] $ids Product IDs to report on.
+ * @return array Reported product IDs, plus the total the CSV export batches on.
+ */
+ private function get_report( array $ids ): array {
+ $request = new WP_REST_Request( 'GET', self::ENDPOINT );
+ $request->set_param( 'include', implode( ',', $ids ) );
+ $request->set_param( 'orderby', 'id' );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertSame( 200, $response->get_status() );
+
+ $headers = $response->get_headers();
+
+ return array(
+ 'ids' => wp_list_pluck( $response->get_data(), 'id' ),
+ 'total' => (int) $headers['X-WP-Total'],
+ );
+ }
+}