Commit 5db98273cb6 for woocommerce
commit 5db98273cb6fcf4f80b5d5986ffd12a490f04bb9
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Thu Jul 16 11:22:15 2026 +0300
Fix dashboard out-of-stock product counts (#66666)
* fix: count dashboard out-of-stock products by status
The WooCommerce status widget counted out-of-stock products with the no-stock quantity threshold. That excluded unmanaged products manually set to out of stock because their lookup-table stock quantity is null even though their stock status is outofstock.
Count the widget row from the canonical wc_product_meta_lookup stock_status value instead. This aligns the dashboard row with the linked Analytics stock report and avoids a cross-column OR between stock_status and stock_quantity.
Add dashboard widget regression coverage for unmanaged out-of-stock products, managed zero-stock normalization, and unmanaged in-stock products.
Refs #29698
* test(admin): Strengthen dashboard stock count coverage
The status-only implementation needs to reject quantity-based fallbacks that misclassify backordered or unmanaged in-stock products.
Cover managed zero-quantity backorders and retain the unmanaged in-stock control. These scenarios distinguish the canonical status predicate from OR conditions based on quantity or nullability.
Refs #29698
* fix(admin): Refresh dashboard stock count after upgrade
The dashboard out-of-stock query now derives its count from stock status, but existing wc_outofstock_count transients were generated with the previous quantity semantics and can live for 30 days.
Register an 11.1.0 database update that deletes only the affected transient. The next widget render recomputes the canonical count while leaving low-stock and product caches untouched.
Refs #29698
* Refactor dashboard out-of-stock transient name into a shared constant
The wc_update_1110 DB update cleared the dashboard out-of-stock count
transient with a bare 'wc_outofstock_count' string literal, duplicating
the name already listed in ProductUtil::delete_product_transients_for_products().
A future rename in one place would silently leave the other stale.
Expose the name as ProductUtil::OUTOFSTOCK_COUNT_TRANSIENT and reference
it from both the runtime invalidation list and the one-time DB update, so
the two paths stay in sync.
* Document save-time stock_status semantics in dashboard count
The out-of-stock count now reads the save-time-computed stock_status
column instead of a live stock_quantity threshold. Add a comment at the
query explaining why (fixes #29698 for stock-unmanaged products, aligns
with the Analytics stock report) and the trade-off: changing the no-stock
threshold option does not reclassify existing products until each is
re-saved, so a future maintainer chasing a stale count has the answer
inline.
* Route dashboard out-of-stock transient read/write through the shared constant
The status widget names, reads, writes, and TTLs the wc_outofstock_count
transient, but still used the bare string literal after the name was
extracted to ProductUtil::OUTOFSTOCK_COUNT_TRANSIENT. That left the
producer of the cached value off the constant while its consumers used
it, so a rename still had to touch this site.
Point the dashboard's $transient_name at the constant (covering both the
get_transient read and the set_transient write, which share the variable)
so the constant is the single source of truth its docblock promises.
diff --git a/plugins/woocommerce/changelog/fix-29698-dashboard-outofstock-count b/plugins/woocommerce/changelog/fix-29698-dashboard-outofstock-count
new file mode 100644
index 00000000000..3f5183199ce
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-29698-dashboard-outofstock-count
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix the dashboard status widget out-of-stock count for unmanaged products.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-dashboard.php b/plugins/woocommerce/includes/admin/class-wc-admin-dashboard.php
index 6aaaf3a6a12..dc325713496 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-dashboard.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-dashboard.php
@@ -9,6 +9,8 @@
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Enums\OrderInternalStatus;
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
@@ -364,7 +366,7 @@ if ( ! class_exists( 'WC_Admin_Dashboard', false ) ) :
set_transient( $transient_name, (int) $lowinstock_count, DAY_IN_SECONDS * 30 );
}
- $transient_name = 'wc_outofstock_count';
+ $transient_name = ProductUtil::OUTOFSTOCK_COUNT_TRANSIENT;
$outofstock_count = get_transient( $transient_name );
$lowstock_url = $lowstock_link ? admin_url( $lowstock_link ) : '#';
$outofstock_url = $outofstock_link ? admin_url( $outofstock_link ) : '#';
@@ -380,14 +382,20 @@ if ( ! class_exists( 'WC_Admin_Dashboard', false ) ) :
$outofstock_count = apply_filters( 'woocommerce_status_widget_out_of_stock_count_pre_query', null, $nostock );
if ( is_null( $outofstock_count ) ) {
+ // Count by the canonical, save-time-computed stock_status column (as the Analytics
+ // stock report does), rather than a live stock_quantity threshold. This also captures
+ // products with stock management disabled (a NULL stock_quantity never matched the old
+ // threshold query — see #29698). Note the trade-off: changing the
+ // woocommerce_notify_no_stock_amount option does not reclassify existing products'
+ // stock_status until each is re-saved, so this count reflects that same staleness.
$outofstock_count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT( product_id )
FROM {$wpdb->wc_product_meta_lookup} AS lookup
INNER JOIN {$wpdb->posts} as posts ON lookup.product_id = posts.ID
- WHERE stock_quantity <= %d
+ WHERE lookup.stock_status = %s
AND posts.post_status = 'publish'",
- $nostock
+ ProductStockStatus::OUT_OF_STOCK
)
);
}
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 5fbc277ece6..9372e5b4be5 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -342,6 +342,9 @@ class WC_Install {
'11.0.0' => array(
'wc_update_1100_enable_point_of_sale_feature',
),
+ '11.1.0' => array(
+ 'wc_update_1110_delete_dashboard_outofstock_count_transient',
+ ),
);
/**
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index ce08857e1bd..5adbd8a6951 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -38,6 +38,7 @@ use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Registe
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize as Download_Directories_Sync;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
use Automattic\WooCommerce\Blocks\Options as BlockOptions;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
@@ -3581,3 +3582,14 @@ function wc_update_10902_remove_deprecated_push_notifications_option(): void {
function wc_update_1100_enable_point_of_sale_feature() {
update_option( 'woocommerce_feature_point_of_sale_enabled', 'yes' );
}
+
+/**
+ * Delete the cached dashboard out-of-stock product count.
+ *
+ * @since 11.1.0
+ *
+ * @return void
+ */
+function wc_update_1110_delete_dashboard_outofstock_count_transient() {
+ delete_transient( ProductUtil::OUTOFSTOCK_COUNT_TRANSIENT );
+}
diff --git a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
index 545d7aa3fa7..8b4c4455798 100644
--- a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
@@ -10,6 +10,19 @@ use Automattic\WooCommerce\Caches\ProductCountCache;
* Class with general utility methods related to products.
*/
class ProductUtil {
+ /**
+ * Transient name for the cached dashboard "out of stock" product count.
+ *
+ * Exposed as a constant so the one-time DB update that clears this transient
+ * (wc_update_1110_delete_dashboard_outofstock_count_transient) stays in sync
+ * with the runtime invalidation list below.
+ *
+ * @since 11.1.0
+ *
+ * @var string
+ */
+ public const OUTOFSTOCK_COUNT_TRANSIENT = 'wc_outofstock_count';
+
/**
* Delete all product transients for a set of products.
*
@@ -26,7 +39,7 @@ class ProductUtil {
$transients_to_clear = array(
'wc_products_onsale',
'wc_featured_products',
- 'wc_outofstock_count',
+ self::OUTOFSTOCK_COUNT_TRANSIENT,
'wc_low_stock_count',
);
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-test.php
index 1f0ff3b42de..22978797280 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-test.php
@@ -1,6 +1,8 @@
<?php
declare( strict_types = 1 );
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+
/**
* Tests for the WC_Admin_Dashboard class.
*
@@ -56,6 +58,8 @@ class WC_Admin_Dashboard_Test extends WC_Unit_Test_Case {
remove_all_filters( 'pre_option_woocommerce_task_list_complete' );
remove_all_filters( 'pre_option_woocommerce_task_list_hidden' );
delete_option( 'woocommerce_enable_reviews' );
+ delete_transient( 'wc_low_stock_count' );
+ delete_transient( 'wc_outofstock_count' );
parent::tearDown();
}
@@ -72,6 +76,25 @@ class WC_Admin_Dashboard_Test extends WC_Unit_Test_Case {
return $method->invoke( $dashboard );
}
+ /**
+ * Invoke the private status_widget_stock_rows method and return its output.
+ *
+ * @return string
+ */
+ private function capture_status_widget_stock_rows(): string {
+ delete_transient( 'wc_low_stock_count' );
+ delete_transient( 'wc_outofstock_count' );
+
+ $method = new ReflectionMethod( WC_Admin_Dashboard::class, 'status_widget_stock_rows' );
+ $method->setAccessible( true );
+
+ ob_start();
+ $method->invoke( $this->sut, '', '' );
+ $html = ob_get_clean();
+
+ return false === $html ? '' : $html;
+ }
+
/**
* @testdox Widget shows when task list is complete.
*/
@@ -259,6 +282,77 @@ class WC_Admin_Dashboard_Test extends WC_Unit_Test_Case {
$this->assertStringContainsString( '<p><span class="spinner is-active"></span><span class="wc-dashboard-widget-loading__text">Loading reviews data...</span></p>', $html );
}
+ /**
+ * @testdox Status widget out-of-stock count includes unmanaged out-of-stock products.
+ */
+ public function test_status_widget_out_of_stock_count_includes_unmanaged_out_of_stock_products(): void {
+ WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => false,
+ 'stock_status' => ProductStockStatus::OUT_OF_STOCK,
+ )
+ );
+
+ $html = $this->capture_status_widget_stock_rows();
+
+ $this->assertStringContainsString( 'Out of stock <strong>1 product</strong>', $html );
+ }
+
+ /**
+ * @testdox Status widget out-of-stock count includes managed products with no stock.
+ */
+ public function test_status_widget_out_of_stock_count_includes_managed_products_with_no_stock(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 0,
+ )
+ );
+
+ $html = $this->capture_status_widget_stock_rows();
+
+ $this->assertSame( ProductStockStatus::OUT_OF_STOCK, $product->get_stock_status( 'edit' ) );
+ $this->assertStringContainsString( 'Out of stock <strong>1 product</strong>', $html );
+ }
+
+ /**
+ * @testdox Status widget out-of-stock count excludes managed products on backorder.
+ */
+ public function test_status_widget_out_of_stock_count_excludes_managed_products_on_backorder(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 0,
+ 'backorders' => 'yes',
+ )
+ );
+
+ $html = $this->capture_status_widget_stock_rows();
+
+ $this->assertSame( ProductStockStatus::ON_BACKORDER, $product->get_stock_status( 'edit' ) );
+ $this->assertStringContainsString( 'Out of stock <strong>0 products</strong>', $html );
+ }
+
+ /**
+ * @testdox Status widget out-of-stock count excludes unmanaged in-stock products.
+ */
+ public function test_status_widget_out_of_stock_count_excludes_unmanaged_in_stock_products(): void {
+ WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => false,
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ )
+ );
+
+ $html = $this->capture_status_widget_stock_rows();
+
+ $this->assertStringContainsString( 'Out of stock <strong>0 products</strong>', $html );
+ }
+
/**
* @testdox Status widget order rows render labels before counts.
*/
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index 18e8ea2a1ea..85fada1a252 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -338,4 +338,21 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
wc_update_1100_enable_point_of_sale_feature();
$this->assertSame( 'yes', get_option( 'woocommerce_feature_point_of_sale_enabled' ) );
}
+
+ /**
+ * @testdox Migration registers and deletes the cached dashboard out-of-stock count.
+ */
+ public function test_wc_update_1110_delete_dashboard_outofstock_count_transient(): void {
+ include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+ $db_updates = WC_Install::get_db_update_callbacks();
+ $this->assertArrayHasKey( '11.1.0', $db_updates );
+ $this->assertContains( 'wc_update_1110_delete_dashboard_outofstock_count_transient', $db_updates['11.1.0'] );
+
+ set_transient( 'wc_outofstock_count', 3, DAY_IN_SECONDS );
+ $this->assertSame( 3, get_transient( 'wc_outofstock_count' ) );
+
+ wc_update_1110_delete_dashboard_outofstock_count_transient();
+ $this->assertFalse( get_transient( 'wc_outofstock_count' ) );
+ }
}