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'],
+		);
+	}
+}