Commit ae4f5b784de for woocommerce

commit ae4f5b784de74c2da779e1dcfd4055af861dbf51
Author: Faisal Ahammad <faisalahammad24@gmail.com>
Date:   Thu Jun 11 22:27:27 2026 +0600

    Fix SKU duplication corrupting hyphenated SKUs (#65423)

    * Fix SKU duplication corrupting hyphenated SKUs

    generate_unique_sku() previously stripped numeric segments after hyphens,
    treating the last number as an incrementable suffix. For SKUs like 'SKU-123',
    this produced 'SKU-124' instead of 'SKU-123-1'.

    Changed to always append -N suffix to the full original SKU. Preserves
    backward compatibility with wc_product_pre_has_unique_sku and
    wc_product_has_unique_sku filter hooks.

    Fixes #65378

    * fix(duplicate-product): use wc_product_generate_unique_sku() fallback for filter compliance

    Replaces direct $original_sku . '-1' fallback with existing
    wc_product_generate_unique_sku() helper that properly checks
    wc_product_pre_has_unique_sku and wc_product_has_unique_sku filters.

    Addresses CodeRabbit review finding on PR #65423.

    * fix(duplicate-product): address PR review feedback

    - Move inline comment before statement to fix linting error
    - Add changelog entry for SKU duplication fix
    - Use strict empty check to avoid treating SKU '0' as empty

    Refs #65423

    * fix(duplicate-product): preserve custom SKU on REST API duplicate when unique

    - Add early-return: if source SKU is not used by any saved product, use it
      as-is. Preserves the previous behavior where the REST API duplicate
      endpoint applies a caller-provided SKU directly when it is unique.
    - Keep the new behavior for source SKUs that already exist: append the
      next available hyphenated suffix (e.g. 'SKU-123' -> 'SKU-123-1').

    Tests run:
    - WC_Admin_Duplicate_Product_Test: 3 tests, 16 assertions, all pass
    - ProductsControllerTest (V4): 93 tests, 727 assertions, all pass
    - WC_REST_Products_Controller_Tests (V3): 73 tests, 4 pre-existing
      meta-param failures (also fail on trunk, not related to this fix)

    Gates: phpcs clean, phpstan 0 errors, coderabbit 0 findings.

    Refs #65423

    * fix(duplicate-product): address case-insensitive SKU and fallback collision issues

    - Replace strpos() with stripos() for case-insensitive prefix matching
      matching MySQL LIKE behavior (#65378)
    - Start fallback from $max_suffix + 1 instead of 1 to avoid trashed
      product SKU collisions
    - Add test_duplicate_product_handles_case_insensitive_sku_conflict
    - PR description updated with submission guidelines and testing details

diff --git a/plugins/woocommerce/changelog/fix-65378-sku-duplication-hyphen b/plugins/woocommerce/changelog/fix-65378-sku-duplication-hyphen
new file mode 100644
index 00000000000..0ac65c09d6d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-65378-sku-duplication-hyphen
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix SKU duplication corrupting hyphenated SKUs. Duplicating a product with SKU `SKU-123` now correctly produces `SKU-123-1` instead of `SKU-124`.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php b/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
index 6e63a047e2b..00bb9177662 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
@@ -310,6 +310,10 @@ class WC_Admin_Duplicate_Product {
 	/**
 	 * Generates a unique sku for a given product.
 	 *
+	 * Appends a numeric suffix to the original SKU. For example, if the original SKU
+	 * is "SKU-123", the duplicate will get "SKU-123-1". This preserves hyphens in the
+	 * original SKU rather than treating them as suffix delimiters.
+	 *
 	 * @param WC_Product $product The product to generate a sku for.
 	 * @return void
 	 * @since 10.5.0
@@ -319,16 +323,30 @@ class WC_Admin_Duplicate_Product {
 	private function generate_unique_sku( $product ) {
 		global $wpdb;

-		// We want to remove the suffix from the sku so that we can find the maximum suffix using this root sku.
-		// This will allow us to find the next-highest suffix that is unique. While this does not support gap
-		// filling, this shouldn't matter for our use-case.
-		$root_sku = preg_replace( '/-[0-9]+$/', '', $product->get_sku() );
+		$original_sku = $product->get_sku();
+
+		// If the product has no SKU, don't do anything.
+		if ( '' === $original_sku ) {
+			return;
+		}
+
+		// If the source SKU is not used by any saved product, use it as-is.
+		// This preserves backward compatibility with the REST API duplicate endpoint,
+		// which lets callers set a custom SKU on the duplicate and expects that SKU
+		// to be applied directly when it is unique.
+		$source_sku_in_use = (bool) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->wc_product_meta_lookup} WHERE sku = %s",
+				$original_sku
+			)
+		);

-		// If the parent product has no SKU, don't do anything.
-		if ( ! $root_sku ) {
+		if ( ! $source_sku_in_use ) {
+			$product->set_sku( $original_sku );
 			return;
 		}

+		// Query existing SKUs that match the pattern: original SKU + hyphen + number.
 		$existing_skus = $wpdb->get_col(
 			$wpdb->prepare(
 				"SELECT lookup.sku
@@ -336,33 +354,46 @@ class WC_Admin_Duplicate_Product {
 					INNER JOIN {$wpdb->wc_product_meta_lookup} AS lookup ON posts.ID = lookup.product_id
 					WHERE posts.post_type IN ( 'product', 'product_variation' )
 					AND lookup.sku LIKE %s",
-				$wpdb->esc_like( $root_sku ) . '%'
+				$wpdb->esc_like( $original_sku ) . '-%'
 			)
 		);

-		// The sku is already unique!
-		if ( empty( $existing_skus ) ) {
-			$product->set_sku( $root_sku );
-			return;
-		}
-
-		// Find the maximum suffix so we can ensure uniqueness.
+		// Find the maximum suffix.
 		$max_suffix = 0;
+		// +1 for the hyphen separator between original SKU and suffix.
+		$prefix_len = strlen( $original_sku ) + 1;
 		foreach ( $existing_skus as $existing_sku ) {
-			// Pull a numerical suffix off the sku after the last hyphen.
-			$suffix = intval( substr( $existing_sku, strrpos( $existing_sku, '-', -1 ) + 1 ) );
-			if ( $suffix > $max_suffix ) {
-				$max_suffix = $suffix;
+			// Check if the SKU starts with our base and has a numeric suffix.
+			if ( stripos( $existing_sku, $original_sku . '-' ) === 0 ) {
+				$suffix_str = substr( $existing_sku, $prefix_len );
+				// Only consider pure numeric suffixes.
+				if ( is_numeric( $suffix_str ) ) {
+					$suffix = intval( $suffix_str );
+					if ( $suffix > $max_suffix ) {
+						$max_suffix = $suffix;
+					}
+				}
 			}
 		}

-		// We set a limit of SKUs to try in order to avoid infinite loops.
-		$limit      = $max_suffix + 100;
+		// Set the new SKU with the next available suffix.
+		$new_sku    = $original_sku . '-' . ( $max_suffix + 1 );
 		$product_id = $product->get_id();

-		while ( $max_suffix < $limit ) {
-			$new_sku = $root_sku . '-' . ( $max_suffix + 1 );
+		/**
+		 * Gives plugins an opportunity to verify SKU uniqueness themselves. Filter added to keep backwards
+		 * compatibility with `wc_product_has_unique_sku()`.
+		 * See: https://github.com/woocommerce/woocommerce/pull/62628
+		 *
+		 * @since 10.5.0
+		 *
+		 * @param bool|null $has_unique_sku Set to a boolean value to short-circuit the default SKU check.
+		 * @param int       $product_id     The ID of the current product.
+		 * @param string    $sku            The SKU to check for uniqueness.
+		 */
+		$pre_has_unique_sku = apply_filters( 'wc_product_pre_has_unique_sku', true, $product_id, $new_sku );

+		if ( $pre_has_unique_sku ) {
 			/**
 			 * Gives plugins an opportunity to verify SKU uniqueness themselves. Filter added to keep backwards
 			 * compatibility with `wc_product_has_unique_sku()`.
@@ -370,33 +401,20 @@ class WC_Admin_Duplicate_Product {
 			 *
 			 * @since 10.5.0
 			 *
-			 * @param bool|null $has_unique_sku Set to a boolean value to short-circuit the default SKU check.
-			 * @param int $product_id The ID of the current product.
-			 * @param string $sku The SKU to check for uniqueness.
+			 * @param bool|null $sku_found Set to a boolean value to short-circuit the default SKU check.
+			 * @param int       $product_id The ID of the current product.
+			 * @param string    $sku       The SKU to check for uniqueness.
 			 */
-			$pre_has_unique_sku = apply_filters( 'wc_product_pre_has_unique_sku', true, $product_id, $new_sku );
+			$sku_found = apply_filters( 'wc_product_has_unique_sku', false, $product_id, $new_sku );

-			if ( $pre_has_unique_sku ) {
-				/**
-				 * Gives plugins an opportunity to verify SKU uniqueness themselves. Filter added to keep backwards
-				 * compatibility with `wc_product_has_unique_sku()`.
-				 * See: https://github.com/woocommerce/woocommerce/pull/62628
-				 *
-				 * @since 10.5.0
-				 *
-				 * @param bool|null $sku_found Set to a boolean value to short-circuit the default SKU check.
-				 * @param int $product_id The ID of the current product.
-				 * @param string $sku The SKU to check for uniqueness.
-				 */
-				$sku_found = apply_filters( 'wc_product_has_unique_sku', false, $product_id, $new_sku );
-
-				if ( ! $sku_found ) {
-					$product->set_sku( $new_sku );
-					return;
-				}
+			if ( ! $sku_found ) {
+				$product->set_sku( $new_sku );
+				return;
 			}
-			++$max_suffix;
 		}
+
+		// Fallback: use the standard unique SKU generator which loops until it finds a unique value.
+		$product->set_sku( wc_product_generate_unique_sku( $product_id, $original_sku, $max_suffix + 1 ) );
 	}
 }

diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php
index 5b39e2e3f34..a2d32055a01 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php
@@ -110,4 +110,74 @@ class WC_Admin_Duplicate_Product_Test extends WC_Unit_Test_Case {

 		remove_filter( 'wc_product_has_unique_sku', array( $this, 'dont_allow_skus_with_numbers_lower_than_8' ) );
 	}
+
+	/**
+	 * Tests that duplicating a product with hyphens in the SKU correctly appends the suffix.
+	 *
+	 * For SKUs like "SKU-123", the duplicate should get "SKU-123-1", not "SKU-124".
+	 * This tests the fix for issue #65378.
+	 */
+	public function test_duplicate_product_with_hyphenated_sku_preserves_original() {
+		// Test case 1: Simple hyphen with number.
+		$product1 = WC_Helper_Product::create_simple_product();
+		$product1->set_sku( 'SKU-123' );
+		$product1->save();
+
+		$duplicate1 = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product1 );
+		$this->assertEquals( 'SKU-123-1', $duplicate1->get_sku(), 'Duplicate of SKU-123 should be SKU-123-1' );
+
+		// Test case 2: Multiple hyphens.
+		$product2 = WC_Helper_Product::create_simple_product();
+		$product2->set_sku( 'my-product-456' );
+		$product2->save();
+
+		$duplicate2 = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product2 );
+		$this->assertEquals( 'my-product-456-1', $duplicate2->get_sku(), 'Duplicate of my-product-456 should be my-product-456-1' );
+
+		// Test case 3: Non-numeric suffix (should still work).
+		$product3 = WC_Helper_Product::create_simple_product();
+		$product3->set_sku( 'PROD-A' );
+		$product3->save();
+
+		$duplicate3 = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product3 );
+		$this->assertEquals( 'PROD-A-1', $duplicate3->get_sku(), 'Duplicate of PROD-A should be PROD-A-1' );
+
+		// Test case 4: Sequential duplicates.
+		$product4 = WC_Helper_Product::create_simple_product();
+		$product4->set_sku( 'SEQ-100' );
+		$product4->save();
+
+		$dup_a = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product4 );
+		$this->assertEquals( 'SEQ-100-1', $dup_a->get_sku() );
+
+		$dup_b = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product4 );
+		$this->assertEquals( 'SEQ-100-2', $dup_b->get_sku() );
+
+		$dup_c = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product4 );
+		$this->assertEquals( 'SEQ-100-3', $dup_c->get_sku() );
+	}
+
+	/**
+	 * Tests that case-insensitive SKU matching prevents collisions.
+	 *
+	 * MySQL LIKE is case-insensitive by default, so 'SKU-%' matches 'sku-1'.
+	 * The prefix check must use stripos() to match SQL behavior.
+	 */
+	public function test_duplicate_product_handles_case_insensitive_sku_conflict() {
+		// Create product with uppercase SKU.
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_sku( 'SKU' );
+		$product->save();
+
+		// Create a different product with lowercase variant + suffix.
+		$other = WC_Helper_Product::create_simple_product();
+		$other->set_sku( 'sku-1' );
+		$other->save();
+
+		// Duplicate the uppercase product.
+		// SQL LIKE 'SKU-%' returns 'sku-1', so max_suffix should be 1.
+		// Expected: 'SKU-2', not 'SKU-1' (which collides with 'sku-1').
+		$duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
+		$this->assertEquals( 'SKU-2', $duplicate->get_sku(), 'Duplicate must not collide with case-variant sku-1' );
+	}
 }