Commit 5c42d25e0f for woocommerce

commit 5c42d25e0fa9227c70c12b9a11c709e4137275a0
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Thu Jan 8 10:00:23 2026 +0100

    Fix error when duplicating a product using an SKU of a trashed product (#62628)

    * Fix error when duplicating a product using an SKU of a trashed product

    * Add changelog file

    * Change  with  param

    * Use sanitize_key instead of esc_sql

    * Fixes

    * Improve comments

    * Fix wrong assertion message

    * Create is_existing_sku_with_excluded_statuses() method

    * Create a generate_unique_sku() directly in WC_Admin_Duplicate_Product

    * Update PHPStan

    * Fix tests

    * Use esc_like instead of wp_slash

    * Add support for existing PHP filters

    * Fix variable name in tests

    * Strengthen LIKE wildcard

    * Add wc_doing_it_wrong() functions

    * Revert "Strengthen LIKE wildcard"

    This reverts commit 8648f04c8e0f469e1e8989389765bca74a203208.

    * Strengthen LIKE wildcard (II)

diff --git a/plugins/woocommerce/changelog/fix-62616-duplicate-trashed-sku b/plugins/woocommerce/changelog/fix-62616-duplicate-trashed-sku
new file mode 100644
index 0000000000..38b12148d0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-62616-duplicate-trashed-sku
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent an error that occurred when duplicating a product if the duplicate's SKU matched the SKU of a trashed product
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 11c6951ff7..6e63a047e2 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
@@ -131,6 +131,11 @@ class WC_Admin_Duplicate_Product {
 	 * @return WC_Product The duplicate.
 	 */
 	public function product_duplicate( $product ) {
+		if ( ! $product instanceof WC_Product ) {
+			wc_doing_it_wrong( __METHOD__, 'product_duplicate() expects a WC_Product instance', '10.5.0' );
+			return new WC_Product();
+		}
+
 		/**
 		 * Filter to allow us to exclude meta keys from product duplication..
 		 *
@@ -157,7 +162,7 @@ class WC_Admin_Duplicate_Product {
 		$duplicate->set_name( sprintf( esc_html__( '%s (Copy)', 'woocommerce' ), $duplicate->get_name() ) );
 		$duplicate->set_total_sales( 0 );
 		if ( '' !== $product->get_sku( 'edit' ) ) {
-			$duplicate->set_sku( wc_product_generate_unique_sku( 0, $product->get_sku( 'edit' ) ) );
+			$this->generate_unique_sku( $duplicate );
 		}
 		if ( '' !== $product->get_global_unique_id( 'edit' ) ) {
 			$duplicate->set_global_unique_id( '' );
@@ -191,6 +196,12 @@ class WC_Admin_Duplicate_Product {
 		if ( ! apply_filters( 'woocommerce_duplicate_product_exclude_children', false, $product ) && $product->is_type( ProductType::VARIABLE ) ) {
 			foreach ( $product->get_children() as $child_id ) {
 				$child = wc_get_product( $child_id );
+
+				if ( ! $child instanceof WC_Product ) {
+					wc_doing_it_wrong( __METHOD__, 'product_duplicate() expects product children to be WC_Product instances', '10.5.0' );
+					continue;
+				}
+
 				$child->read_meta_data();
 				$child_duplicate = clone $child;
 				$child_duplicate->set_parent_id( $duplicate->get_id() );
@@ -204,7 +215,7 @@ class WC_Admin_Duplicate_Product {
 				$this->generate_unique_slug( $child_duplicate );

 				if ( '' !== $child->get_sku( 'edit' ) ) {
-					$child_duplicate->set_sku( wc_product_generate_unique_sku( 0, $child->get_sku( 'edit' ) ) );
+					$this->generate_unique_sku( $child_duplicate );
 				}
 				if ( '' !== $child->get_global_unique_id( 'edit' ) ) {
 					$child_duplicate->set_global_unique_id( '' );
@@ -295,6 +306,98 @@ class WC_Admin_Duplicate_Product {

 		$product->set_slug( $root_slug . '-' . ( $max_suffix + 1 ) );
 	}
+
+	/**
+	 * Generates a unique sku for a given product.
+	 *
+	 * @param WC_Product $product The product to generate a sku for.
+	 * @return void
+	 * @since 10.5.0
+	 *
+	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+	 */
+	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() );
+
+		// If the parent product has no SKU, don't do anything.
+		if ( ! $root_sku ) {
+			return;
+		}
+
+		$existing_skus = $wpdb->get_col(
+			$wpdb->prepare(
+				"SELECT lookup.sku
+					FROM {$wpdb->posts} as posts
+					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 ) . '%'
+			)
+		);
+
+		// The sku is already unique!
+		if ( empty( $existing_skus ) ) {
+			$product->set_sku( $root_sku );
+			return;
+		}
+
+		// Find the maximum suffix so we can ensure uniqueness.
+		$max_suffix = 0;
+		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;
+			}
+		}
+
+		// We set a limit of SKUs to try in order to avoid infinite loops.
+		$limit      = $max_suffix + 100;
+		$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()`.
+				 * 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;
+				}
+			}
+			++$max_suffix;
+		}
+	}
 }

 return new WC_Admin_Duplicate_Product();
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 5bd8a2c10a..061394480e 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -2508,30 +2508,6 @@ parameters:
 			count: 1
 			path: includes/admin/class-wc-admin-duplicate-product.php

-		-
-			message: '#^Cannot call method get_global_unique_id\(\) on WC_Product\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/admin/class-wc-admin-duplicate-product.php
-
-		-
-			message: '#^Cannot call method get_sku\(\) on WC_Product\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 2
-			path: includes/admin/class-wc-admin-duplicate-product.php
-
-		-
-			message: '#^Cannot call method read_meta_data\(\) on WC_Product\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/admin/class-wc-admin-duplicate-product.php
-
-		-
-			message: '#^Cannot clone non\-object variable \$child of type WC_Product\|false\|null\.$#'
-			identifier: clone.nonObject
-			count: 1
-			path: includes/admin/class-wc-admin-duplicate-product.php
-
 		-
 			message: '#^Method WC_Admin_Duplicate_Product\:\:dupe_button\(\) has no return type specified\.$#'
 			identifier: missingType.return
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 a9e5e7934c..5b39e2e3f3 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
@@ -1,4 +1,6 @@
 <?php
+declare( strict_types = 1 );
+
 /**
  * Unit tests for the WC_Admin_Duplicate_Product class.
  *
@@ -9,6 +11,38 @@
  * Class WC_Admin_Duplicate_Product_Test
  */
 class WC_Admin_Duplicate_Product_Test extends WC_Unit_Test_Case {
+	/**
+	 * Don't allow SKUs with numbers lower than 6. Used to hook into `wc_product_pre_has_unique_sku`.
+	 *
+	 * @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.
+	 * @return bool
+	 */
+	public function dont_allow_skus_with_numbers_lower_than_6( $has_unique_sku, $product_id, $sku ) {
+		if ( preg_match( '/[0-5]/', $sku ) ) {
+			return false;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Don't allow SKUs with numbers lower than 8. Used to hook into `wc_product_has_unique_sku`.
+	 *
+	 * @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.
+	 * @return bool
+	 */
+	public function dont_allow_skus_with_numbers_lower_than_8( $sku_found, $product_id, $sku ) {
+		if ( preg_match( '/[0-7]/', $sku ) ) {
+			return true;
+		}
+
+		return false;
+	}
+
 	/**
 	 * Tests that the filter will exclude metadata from the duplicate as-expected.
 	 */
@@ -29,4 +63,51 @@ class WC_Admin_Duplicate_Product_Test extends WC_Unit_Test_Case {
 		$this->assertNotEquals( $product->get_id(), $duplicate->get_id() );
 		$this->assertEmpty( $duplicate->get_meta_data() );
 	}
+
+	/**
+	 * Tests that duplicating a product correctly handles trashed products with indexed SKUs.
+	 *
+	 * When duplicating a product with SKU 'woo-cap', if there's a trashed product
+	 * with SKU 'woo-cap-1', the duplicate should get SKU 'woo-cap-2' instead of throwing an error.
+	 */
+	public function test_duplicate_product_skips_trashed_products_with_indexed_sku() {
+		// Create a product with SKU 'woo-cap'.
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_sku( 'woo-cap' );
+		$product->save();
+
+		// Create a duplicate and trash it.
+		$first_duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
+		$first_duplicate->save();
+		$first_duplicate->delete();
+
+		// Verify the trashed product is in trash.
+		$this->assertEquals( 'trash', $first_duplicate->get_status() );
+
+		// Duplicate the original product.
+		$second_duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
+
+		// Verify the duplicate was created successfully and has SKU 'woo-cap-2'
+		// (skipping 'woo-cap-1' which is trashed).
+		$this->assertEquals( 'woo-cap-2', $second_duplicate->get_sku() );
+		$this->assertNotEquals( $product->get_id(), $second_duplicate->get_id() );
+
+		// Verify the filter 'wc_product_pre_has_unique_sku' is honored.
+		add_filter( 'wc_product_pre_has_unique_sku', array( $this, 'dont_allow_skus_with_numbers_lower_than_6' ), 10, 3 );
+
+		$third_duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
+		$this->assertEquals( 'woo-cap-6', $third_duplicate->get_sku() );
+		$this->assertNotEquals( $product->get_id(), $third_duplicate->get_id() );
+
+		remove_filter( 'wc_product_pre_has_unique_sku', array( $this, 'dont_allow_skus_with_numbers_lower_than_6' ) );
+
+		// Verify the filter 'wc_product_has_unique_sku' is honored.
+		add_filter( 'wc_product_has_unique_sku', array( $this, 'dont_allow_skus_with_numbers_lower_than_8' ), 10, 3 );
+
+		$fourth_duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
+		$this->assertEquals( 'woo-cap-8', $fourth_duplicate->get_sku() );
+		$this->assertNotEquals( $product->get_id(), $fourth_duplicate->get_id() );
+
+		remove_filter( 'wc_product_has_unique_sku', array( $this, 'dont_allow_skus_with_numbers_lower_than_8' ) );
+	}
 }