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