Commit 1a576362ad4 for woocommerce
commit 1a576362ad4db99171f98cbd604f119c92d5f407
Author: Luigi Teschio <gigitux@gmail.com>
Date: Mon Aug 10 19:18:37 2026 +0200
Fix grouped products retaining out-of-stock status (#67484)
* Fix grouped products retaining out-of-stock status
* Add changelog entry for grouped product stock fix
* Simplify grouped product stock status coverage
* implement fix
* fix logic for external product
diff --git a/plugins/woocommerce/changelog/fix-28979-grouped-product-stock-status b/plugins/woocommerce/changelog/fix-28979-grouped-product-stock-status
new file mode 100644
index 00000000000..5c0c96dbe4d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-28979-grouped-product-stock-status
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Reset stock management and stock status when converting products to grouped or external types.
diff --git a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
index 103e971788d..b92a4a04aee 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
@@ -161,9 +161,21 @@ jQuery( function ( $ ) {
} else if ( 'grouped' === select_val ) {
$( 'input#_downloadable' ).prop( 'checked', false );
$( 'input#_virtual' ).prop( 'checked', false );
+ $( 'input#_manage_stock' )
+ .prop( 'checked', false )
+ .trigger( 'change' );
+ $( '[name="_stock_status"]' )
+ .val( [ 'instock' ] )
+ .trigger( 'change' );
} else if ( 'external' === select_val ) {
$( 'input#_downloadable' ).prop( 'checked', false );
$( 'input#_virtual' ).prop( 'checked', false );
+ $( 'input#_manage_stock' )
+ .prop( 'checked', false )
+ .trigger( 'change' );
+ $( '[name="_stock_status"]' )
+ .val( [ 'instock' ] )
+ .trigger( 'change' );
}
const cogs_field_tip = $( '._cogs_value_field' ).find(
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
index c71e98f1c8a..c48d1b713fe 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
@@ -9,6 +9,7 @@
*/
use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\POSProductVisibilitySync;
@@ -312,7 +313,8 @@ class WC_Meta_Box_Product_Data {
// Don't use wc_clean as it destroys sanitized characters.
$value = sanitize_title( $value );
} else {
- $value = html_entity_decode( wc_clean( $value ), ENT_QUOTES, get_bloginfo( 'charset' ) ); // WPCS: sanitization ok.
+ $value = html_entity_decode( wc_clean( $value ), ENT_QUOTES, get_bloginfo( 'charset' ) );
+ // WPCS: sanitization ok.
}
$attributes[ $attribute_key ] = $value;
@@ -442,8 +444,14 @@ class WC_Meta_Box_Product_Data {
*/
do_action( 'woocommerce_admin_process_product_object', $product );
+ $previous_product_type = WC_Product_Factory::get_product_type( $post_id );
+
$product->save();
+ if ( $product instanceof WC_Product && ProductType::EXTERNAL === $product_type && $previous_product_type !== $product_type ) {
+ self::persist_external_product_stock_data( $product );
+ }
+
if ( $product->is_type( ProductType::VARIABLE ) ) {
$original_post_title = isset( $_POST['original_post_title'] ) ? wc_clean( wp_unslash( $_POST['original_post_title'] ) ) : '';
$post_title = isset( $_POST['post_title'] ) ? wc_clean( wp_unslash( $_POST['post_title'] ) ) : '';
@@ -455,6 +463,32 @@ class WC_Meta_Box_Product_Data {
/* phpcs:enable WordPress.Security.NonceVerification.Missing and WooCommerce.Commenting.CommentHooks.MissingHookComment */
}
+ /**
+ * Persist the stock data enforced by the external product class.
+ *
+ * External product setters normalize stock values while the existing product data is read. The submitted
+ * values therefore match the in-memory values and are not detected as changes, leaving stale post meta and
+ * lookup data behind unless they are synchronized explicitly.
+ *
+ * @param WC_Product $product External product object.
+ */
+ private static function persist_external_product_stock_data( WC_Product $product ): void {
+ update_post_meta( $product->get_id(), '_manage_stock', 'no' );
+ update_post_meta( $product->get_id(), '_stock_status', ProductStockStatus::IN_STOCK );
+ update_post_meta( $product->get_id(), '_backorders', 'no' );
+
+ /**
+ * Product data store.
+ *
+ * @var WC_Data_Store $data_store
+ */
+ $data_store = $product->get_data_store();
+ if ( $data_store->has_callable( 'refresh_product_lookup_table' ) ) {
+ // @phpstan-ignore-next-line method.notFound (Guarded by has_callable() and called via __call() on the underlying product data store instance.)
+ $data_store->refresh_product_lookup_table( $product->get_id() );
+ }
+ }
+
/**
* Save variation meta box data.
*
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-grouped-stock-status.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-grouped-stock-status.spec.ts
new file mode 100644
index 00000000000..bc2387e3250
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-grouped-stock-status.spec.ts
@@ -0,0 +1,89 @@
+/**
+ * External dependencies
+ */
+import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { test as baseTest, expect } from '../../fixtures/fixtures';
+import { ADMIN_STATE_PATH } from '../../playwright.config';
+import { getFakeProduct } from '../../utils/data';
+
+const test = baseTest.extend( {
+ storageState: ADMIN_STATE_PATH,
+ managedOutOfStockProduct: async ( { restApi }, fixtureUse ) => {
+ const response = await restApi.post( `${ WC_API_PATH }/products`, {
+ ...getFakeProduct(),
+ manage_stock: true,
+ stock_quantity: 0,
+ stock_status: 'outofstock',
+ } );
+
+ await fixtureUse( response.data );
+
+ await restApi.delete(
+ `${ WC_API_PATH }/products/${ response.data.id }`,
+ { force: true }
+ );
+ },
+} );
+
+for ( const productType of [ 'grouped', 'external' ] ) {
+ test( `resets stock settings when converting a product to ${ productType }`, async ( {
+ page,
+ restApi,
+ managedOutOfStockProduct,
+ } ) => {
+ await page.goto(
+ `wp-admin/post.php?post=${ managedOutOfStockProduct.id }&action=edit`
+ );
+
+ await expect( page.locator( 'input#_manage_stock' ) ).toBeChecked();
+ await expect(
+ page.locator( 'input[name="_stock_status"][value="outofstock"]' )
+ ).toBeChecked();
+
+ await page.locator( '#product-type' ).selectOption( productType );
+
+ await expect( page.locator( 'input#_manage_stock' ) ).not.toBeChecked();
+ await expect(
+ page.locator( 'input[name="_stock_status"][value="instock"]' )
+ ).toBeChecked();
+
+ await page
+ .locator( '#publishing-action' )
+ .getByRole( 'button', { name: 'Update' } )
+ .click();
+ await expect(
+ page
+ .locator( 'div.notice-success > p' )
+ .filter( { hasText: 'Product updated' } )
+ ).toBeVisible();
+
+ const response = await restApi.get(
+ `${ WC_API_PATH }/products/${ managedOutOfStockProduct.id }`
+ );
+
+ expect( response.data.type ).toBe( productType );
+ expect( response.data.manage_stock ).toBe( false );
+ expect( response.data.stock_status ).toBe( 'instock' );
+
+ const productSearch = encodeURIComponent(
+ managedOutOfStockProduct.name
+ );
+ await page.goto(
+ `wp-admin/edit.php?post_type=product&stock_status=instock&s=${ productSearch }`
+ );
+ await expect(
+ page.locator( `#post-${ managedOutOfStockProduct.id }` )
+ ).toBeVisible();
+
+ await page.goto(
+ `wp-admin/edit.php?post_type=product&stock_status=outofstock&s=${ productSearch }`
+ );
+ await expect(
+ page.locator( `#post-${ managedOutOfStockProduct.id }` )
+ ).toHaveCount( 0 );
+ } );
+}