Commit 8f42ea4d6f5 for woocommerce

commit 8f42ea4d6f5ab728d5d6b8723b03558a00007d89
Author: Ján Mikláš <neosinner@gmail.com>
Date:   Wed Nov 11 17:10:21 2026 +0100

    Fix SKU relationships in product CSV updates (#70722)

    * Fix SKU relationships in product CSV updates

    * Add changelog for CSV relationship fix

    * Update plugins/woocommerce/includes/import/class-wc-product-csv-importer.php

    Co-authored-by: Justin P <228780+layoutd@users.noreply.github.com>

    * Test CSV updates with a trashed upsell

    * Save CSV updates with unresolved optional links

    * Use linked product wording in CSV import warning summary

    Match the Linked Products tab naming, per review feedback.

    Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01NM9YXsVDzsdS8dAv5aGByh

    ---------

    Co-authored-by: Justin P <228780+layoutd@users.noreply.github.com>
    Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-37437-csv-sku-relationships b/plugins/woocommerce/changelog/fix-37437-csv-sku-relationships
new file mode 100644
index 00000000000..c81beb28865
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-37437-csv-sku-relationships
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix CSV updates that rename linked SKUs and warn when an unavailable SKU leaves an optional link field unchanged.
diff --git a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
index 77dce95a308..da0b0170a88 100644
--- a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
+++ b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
@@ -555,7 +555,7 @@ class WC_Product_CSV_Importer_Controller {
 				'parse'              => true,
 			);

-			// Log failures.
+			// Load the import log from earlier batches.
 			if ( 0 !== $params['start_pos'] ) {
 				$error_log = array_filter( (array) get_user_option( 'product_import_error_log' ) );
 			} else {
@@ -571,7 +571,7 @@ class WC_Product_CSV_Importer_Controller {
 			$importer         = self::get_importer( $file, $params );
 			$results          = $importer->import();
 			$percent_complete = $importer->get_percent_complete();
-			$error_log        = array_merge( $error_log, $results['failed'], $results['skipped'] );
+			$error_log        = array_merge( $error_log, $results['failed'], $results['skipped'], $results['warnings'] ?? array() );

 			if ( 100 === $percent_complete ) {
 				self::delete_original_id_markers();
diff --git a/plugins/woocommerce/includes/admin/importers/views/html-csv-import-done.php b/plugins/woocommerce/includes/admin/importers/views/html-csv-import-done.php
index d74a37eebd7..8760eb49378 100644
--- a/plugins/woocommerce/includes/admin/importers/views/html-csv-import-done.php
+++ b/plugins/woocommerce/includes/admin/importers/views/html-csv-import-done.php
@@ -54,7 +54,15 @@ if ( ! defined( 'ABSPATH' ) ) {
 			);
 		}

-		if ( 0 < $failed || 0 < $skipped ) {
+		foreach ( $errors as $entry ) {
+			$error_data = $entry instanceof WP_Error ? $entry->get_error_data() : null;
+			if ( is_array( $error_data ) && 'warning' === ( $error_data['severity'] ?? '' ) ) {
+				$results[] = __( 'Some linked product fields were skipped', 'woocommerce' );
+				break;
+			}
+		}
+
+		if ( 0 < $failed || 0 < $skipped || $errors ) {
 			$results[] = '<a href="#" class="woocommerce-importer-done-view-errors">' . __( 'View import log', 'woocommerce' ) . '</a>';
 		}

@@ -75,7 +83,7 @@ if ( ! defined( 'ABSPATH' ) ) {
 			<thead>
 				<tr>
 					<th><?php esc_html_e( 'Product', 'woocommerce' ); ?></th>
-					<th><?php esc_html_e( 'Reason for failure', 'woocommerce' ); ?></th>
+					<th><?php esc_html_e( 'Details', 'woocommerce' ); ?></th>
 				</tr>
 			</thead>
 			<tbody>
diff --git a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
index 22528500780..7ba5c7fac75 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -34,6 +34,30 @@ if ( ! class_exists( 'WC_Product_CSV_Importer_Controller', false ) ) {
  */
 class WC_Product_CSV_Importer extends WC_Product_Importer {

+	/**
+	 * CSV relationship columns and their corresponding keys after expansion.
+	 */
+	private const RELATIONSHIP_FIELDS = array(
+		'parent_id'        => 'parent_id',
+		'grouped_products' => 'children',
+		'upsell_ids'       => 'upsell_ids',
+		'cross_sell_ids'   => 'cross_sell_ids',
+	);
+
+	/**
+	 * Product IDs and unresolved SKUs from the current update relationship cell.
+	 *
+	 * @var list<int|string>|null Null outside relationship formatting.
+	 */
+	private $relationship_references = null;
+
+	/**
+	 * Relationship values containing an unresolved SKU, keyed by parsed row index and field.
+	 *
+	 * @var array
+	 */
+	private $pending_relationships = array();
+
 	/**
 	 * Tracks current row being parsed.
 	 *
@@ -254,14 +278,30 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 	 * If mapping to an id: and the product ID does not exist, this link is not
 	 * valid.
 	 *
-	 * If mapping to a SKU and the product ID does not exist, a temporary object
-	 * will be created so it can be updated later.
+	 * An unresolved SKU in an update row returns an empty string until the row runs.
+	 * In a new-product import, an unresolved SKU creates a temporary product.
 	 *
 	 * @param string $value Field value.
 	 *
 	 * @return int|string
 	 */
 	public function parse_relative_field( $value ) {
+		$reference = $this->parse_relative_reference( $value );
+		if ( null === $this->relationship_references || '' === $reference ) {
+			return $reference;
+		}
+
+		$this->relationship_references[] = $reference;
+		return is_int( $reference ) ? $reference : '';
+	}
+
+	/**
+	 * Parse a relationship reference, retaining the SKU if an update needs to retry it.
+	 *
+	 * @param string $value Field value.
+	 * @return int|string Product ID, unresolved SKU, or an empty string.
+	 */
+	private function parse_relative_reference( $value ) {
 		global $wpdb;

 		if ( empty( $value ) ) {
@@ -303,6 +343,11 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 			return $id;
 		}

+		if ( null !== $this->relationship_references ) {
+			$id = $this->get_relationship_product_id_by_sku( $value );
+			return $id ? $id : $value;
+		}
+
 		$id = wc_get_product_id_by_sku( $value );

 		if ( $id ) {
@@ -326,6 +371,87 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 		return '';
 	}

+	/**
+	 * Find the saved product for a relationship SKU, excluding temporary products.
+	 *
+	 * @param string $sku Product SKU.
+	 * @return int Product ID, or zero if the SKU is unavailable.
+	 */
+	private function get_relationship_product_id_by_sku( string $sku ): int {
+		$id     = wc_get_product_id_by_sku( $sku );
+		$status = $id ? get_post_status( $id ) : false;
+
+		return $status && ! in_array( $status, array( 'importing', 'importing-cleanup', ProductStatus::TRASH ), true ) ? $id : 0;
+	}
+
+	/**
+	 * Keep unresolved SKUs unless a formatting callback supplied a replacement.
+	 *
+	 * @param mixed $formatted Result of formatting the relationship cell.
+	 * @return list<int|string>|string|null References to resolve, or null if none are pending.
+	 */
+	private function get_pending_relationship( $formatted ) {
+		$references = $this->relationship_references;
+		if ( ! $references ) {
+			return null;
+		}
+
+		$resolved = array_filter( $references, 'is_int' );
+		if ( count( $resolved ) < count( $references ) && is_array( $formatted ) && array_values( $formatted ) === array_values( $resolved ) ) {
+			return $references;
+		}
+
+		return '' === $formatted && 1 === count( $references ) && is_string( $references[0] ) ? $references[0] : null;
+	}
+
+	/**
+	 * Retry unresolved SKUs before saving and omit optional fields that still cannot be resolved.
+	 *
+	 * @param array $row           Parsed product data.
+	 * @param array $relationships Pending relationship values keyed by product field.
+	 * @param array $warnings      Warnings for optional fields omitted from this row.
+	 * @return array|WP_Error Product data or an error if the parent SKU cannot be resolved.
+	 */
+	private function resolve_pending_relationships( array $row, array $relationships, array &$warnings ) {
+		foreach ( $relationships as $field => $value ) {
+			$references = (array) $value;
+			foreach ( $references as $index => $sku ) {
+				if ( ! is_string( $sku ) ) {
+					continue;
+				}
+
+				$id = $this->get_relationship_product_id_by_sku( $sku );
+				if ( ! $id ) {
+					if ( 'parent_id' === $field ) {
+						return new WP_Error(
+							'woocommerce_product_importer_variation_parent_not_variable',
+							/* translators: %s: SKU of the product referenced by the CSV row. */
+							sprintf( esc_html__( 'No product was found with the SKU "%s".', 'woocommerce' ), esc_html( $sku ) )
+						);
+					}
+
+					$labels     = array(
+						'children'       => __( 'Grouped products', 'woocommerce' ),
+						'upsell_ids'     => __( 'Upsells', 'woocommerce' ),
+						'cross_sell_ids' => __( 'Cross-sells', 'woocommerce' ),
+					);
+					$warnings[] = new WP_Error(
+						'woocommerce_product_importer_unresolved_relationship_sku',
+						/* translators: 1: CSV field name, 2: SKU of the product referenced by the CSV row. */
+						sprintf( esc_html__( 'Skipped the "%1$s" field because no product was found with the SKU "%2$s".', 'woocommerce' ), esc_html( $labels[ $field ] ), esc_html( $sku ) )
+					);
+					unset( $row[ $field ] );
+					continue 2;
+				}
+
+				$references[ $index ] = $id;
+			}
+			$row[ $field ] = is_array( $value ) ? $references : reset( $references );
+		}
+
+		return $row;
+	}
+
 	/**
 	 * Parse the ID field.
 	 *
@@ -1373,7 +1499,8 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {

 			$this->parsing_raw_data_index = $row_index;

-			$data = array();
+			$data          = array();
+			$relationships = array();

 			do_action( 'woocommerce_product_importer_before_set_parsed_data', $row, $mapped_keys );

@@ -1383,18 +1510,48 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 					continue;
 				}

-				$data[ $mapped_keys[ $id ] ] = call_user_func( $parse_functions[ $id ], self::convert_to_utf8( $value ) );
+				$field = $mapped_keys[ $id ];
+				$value = self::convert_to_utf8( $value );
+				if ( ! $this->params['update_existing'] || ! isset( self::RELATIONSHIP_FIELDS[ $field ] ) ) {
+					$data[ $field ] = call_user_func( $parse_functions[ $id ], $value );
+					continue;
+				}
+
+				$this->relationship_references = array();
+				try {
+					$data[ $field ] = call_user_func( $parse_functions[ $id ], $value );
+					$pending        = $this->get_pending_relationship( $data[ $field ] );
+				} finally {
+					$this->relationship_references = null;
+				}
+
+				if ( null === $pending ) {
+					unset( $relationships[ $field ] );
+				} else {
+					$relationships[ $field ] = $pending;
+				}
 			}

+			$row = $this->expand_data( $data );
+
 			/**
 			 * Filter product importer parsed data.
 			 *
 			 * @param array $parsed_data Parsed data.
 			 * @param WC_Product_Importer $importer Importer instance.
 			 *
-			 * @since
+			 * @since 3.1.0
 			 */
-			$this->parsed_data[] = apply_filters( 'woocommerce_product_importer_parsed_data', $this->expand_data( $data ), $this );
+			$parsed_row = apply_filters( 'woocommerce_product_importer_parsed_data', $row, $this );
+			foreach ( $relationships as $field => $value ) {
+				$product_field = self::RELATIONSHIP_FIELDS[ $field ];
+				$filtered      = is_array( $parsed_row ) ? ( $parsed_row[ $product_field ] ?? null ) : null;
+				// A filter may already have supplied a replacement relationship.
+				if ( $filtered === $data[ $field ] || ( is_array( $filtered ) && is_array( $data[ $field ] ) && array_values( $filtered ) === array_values( $data[ $field ] ) ) ) {
+					$this->pending_relationships[ count( $this->parsed_data ) ][ $product_field ] = $value;
+				}
+			}
+			$this->parsed_data[] = $parsed_row;
 		}
 	}

@@ -1578,7 +1735,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 	 * update existing is false, and likewise, if updating products, do not
 	 * process rows which do not exist if an ID, SKU, or non-blank Global Unique ID is provided.
 	 *
-	 * @return array
+	 * @return array Import results, with a warnings list when a saved row skipped an optional field.
 	 */
 	public function import() {
 		$this->start_time = time();
@@ -1593,6 +1750,12 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 		);

 		foreach ( $this->parsed_data as $parsed_data_key => $parsed_data ) {
+			$row_warnings  = array();
+			$relationships = isset( $this->pending_relationships[ $parsed_data_key ] ) ? $this->resolve_pending_relationships( $parsed_data, $this->pending_relationships[ $parsed_data_key ], $row_warnings ) : $parsed_data;
+			if ( ! is_wp_error( $relationships ) ) {
+				$parsed_data = $relationships;
+			}
+
 			do_action( 'woocommerce_product_import_before_import', $parsed_data );

 			$id  = isset( $parsed_data['id'] ) ? absint( $parsed_data['id'] ) : 0;
@@ -1659,7 +1822,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 				$refusal          = null;

 				if ( ProductType::VARIATION === ( $parsed_data['type'] ?? '' ) ) {
-					$can_create_variation = $this->can_create_variation( $parsed_data );
+					$can_create_variation = is_wp_error( $relationships ) ? $relationships : $this->can_create_variation( $parsed_data );

 					// Anything other than an explicit pass refuses, so an override still written against
 					// the previous boolean contract cannot turn a refusal into a creation.
@@ -1697,7 +1860,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 				}
 			}

-			$result = $this->process_item( $parsed_data );
+			$result = is_wp_error( $relationships ) ? $relationships : $this->process_item( $parsed_data );

 			if ( is_wp_error( $result ) ) {
 				if ( $id_exists && $sku_exists && $id !== $id_from_sku ) {
@@ -1713,6 +1876,17 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 				$data['imported'][] = $result['id'];
 			}

+			if ( $row_warnings && ! $result instanceof WP_Error ) {
+				$warning_data = array(
+					'row'      => $this->get_row_id( $parsed_data ),
+					'severity' => 'warning',
+				);
+				foreach ( $row_warnings as $warning ) {
+					$warning->add_data( $warning_data );
+					$data['warnings'][] = $warning;
+				}
+			}
+
 			++$index;

 			if ( $this->params['prevent_timeouts'] && ( $this->time_exceeded() || $this->memory_exceeded() ) ) {
diff --git a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
index afb69a63c5f..1d46d85cd0e 100644
--- a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
@@ -564,6 +564,46 @@ class WC_Product_CSV_Importer_Controller_Test extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox The import log shows warnings from earlier batches without counting the rows as skipped.
+	 */
+	public function test_import_log_shows_warnings_for_updated_products(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$this->assertIsInt( $user_id );
+		wp_set_current_user( $user_id );
+
+		$first  = WC_Helper_Product::create_simple_product();
+		$second = WC_Helper_Product::create_simple_product();
+		$csv    = "ID,Name,Upsells\n{$first->get_id()},First update,missing-upsell\n{$second->get_id()},Second update,\n";
+
+		add_filter( 'woocommerce_product_import_batch_size', array( $this, 'return_one' ) );
+		$response = $this->dispatch_import_request( '0', $csv, true );
+		$this->assertSame( 1, $response['data']['updated'] );
+		$this->assertSame( 0, $response['data']['skipped'] );
+
+		$response = $this->dispatch_import_request( (string) $response['data']['position'], $csv, true );
+		$this->assertSame( 'cleanup:0', $response['data']['position'] );
+		$this->assertSame( 1, $response['data']['updated'] );
+		$this->assertSame( 0, $response['data']['skipped'] );
+
+		$sut                  = new WC_Product_CSV_Importer_Controller();
+		$done                 = new ReflectionMethod( $sut, 'done' );
+		$_REQUEST['_wpnonce'] = wp_create_nonce( 'woocommerce-csv-importer' );
+		$done->setAccessible( true );
+		ob_start();
+		try {
+			$done->invoke( $sut );
+		} finally {
+			$html = (string) ob_get_clean();
+			unset( $_REQUEST['_wpnonce'] );
+		}
+
+		$this->assertStringContainsString( 'Some linked product fields were skipped', $html );
+		$this->assertStringContainsString( 'View import log', $html );
+		$this->assertStringContainsString( 'First update, ID ' . $first->get_id(), $html );
+		$this->assertStringContainsString( 'missing-upsell', $html );
+	}
+
 	/**
 	 * @testdox Retrying an abandoned run with the same CSV should skip rows without a SKU that the abandoned run imported.
 	 */
@@ -610,21 +650,23 @@ class WC_Product_CSV_Importer_Controller_Test extends WC_Unit_Test_Case {
 	 *
 	 * The AJAX wp_die() handler throws an Error because dispatch_ajax() catches Exception.
 	 *
-	 * @param string $position Import position to request.
-	 * @param string $csv      CSV contents to import.
+	 * @param string $position        Import position to request.
+	 * @param string $csv             CSV contents to import.
+	 * @param bool   $update_existing Whether to update existing products.
 	 * @return array
 	 */
-	private function dispatch_import_request( string $position, string $csv = self::MARKER_CSV ): array {
+	private function dispatch_import_request( string $position, string $csv = self::MARKER_CSV, bool $update_existing = false ): array {
 		$nonce = wp_create_nonce( 'wc-product-import' );
 		$file  = $this->write_import_csv( $csv );

-		$_REQUEST['security'] = $nonce;
-		$_POST['security']    = $nonce;
-		$_POST['file']        = $file;
-		$_POST['position']    = $position;
-		$_POST['mapping']     = array(
-			'from' => array( 'ID', 'Type', 'SKU', 'Name', 'Parent' ),
-			'to'   => array( 'id', 'type', 'sku', 'name', 'parent_id' ),
+		$_REQUEST['security']     = $nonce;
+		$_POST['security']        = $nonce;
+		$_POST['file']            = $file;
+		$_POST['position']        = $position;
+		$_POST['update_existing'] = $update_existing;
+		$_POST['mapping']         = array(
+			'from' => array( 'ID', 'Type', 'SKU', 'Name', 'Parent', 'Upsells' ),
+			'to'   => array( 'id', 'type', 'sku', 'name', 'parent_id', 'upsell_ids' ),
 		);

 		add_filter( 'wp_doing_ajax', '__return_true' );
@@ -643,7 +685,7 @@ class WC_Product_CSV_Importer_Controller_Test extends WC_Unit_Test_Case {
 			remove_filter( 'wp_die_ajax_handler', array( $this, 'get_throwing_die_handler' ) );

 			wp_delete_file( $file );
-			unset( $_POST['file'], $_POST['position'], $_POST['mapping'], $_POST['security'], $_REQUEST['security'] );
+			unset( $_POST['file'], $_POST['position'], $_POST['mapping'], $_POST['security'], $_POST['update_existing'], $_REQUEST['security'] );
 		}

 		$decoded = json_decode( $response, true );
diff --git a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
index 21de54310d7..ade454ce6a7 100644
--- a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
+++ b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
@@ -35,6 +35,180 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
 		parent::tearDown();
 	}

+	/**
+	 * @testdox An update import resolves relationship SKUs saved by earlier rows in the same import.
+	 * @testWith [-1]
+	 *           [1]
+	 *
+	 * @param int $batch_size Rows per request, or -1 to read the entire file.
+	 */
+	public function test_import_updates_skus_and_relationships_together( int $batch_size ): void {
+		$parent = new WC_Product_Variable();
+		$parent->set_sku( 'parent-before-import' );
+		$parent->save();
+		$variation = new WC_Product_Variation();
+		$variation->set_sku( 'variation-before-import' );
+		$variation->set_parent_id( $parent->get_id() );
+		$variation->save();
+		$linked = WC_Helper_Product::create_simple_product();
+		$group  = new WC_Product_Grouped();
+		$group->save();
+
+		$csv = "id,type,sku,parent_id,grouped_products,upsell_ids,cross_sell_ids\n"
+			. "{$parent->get_id()},variable,parent-after-import,,,,\n"
+			. "{$variation->get_id()},variation,variation-after-import,parent-after-import,,,\n"
+			. "{$linked->get_id()},simple,linked-after-import,,,,\n"
+			. "{$group->get_id()},grouped,group-after-import,,linked-after-import,\"parent-after-import,id:{$linked->get_id()}\",linked-after-import\n";
+
+		add_filter(
+			'woocommerce_product_importer_parsed_data',
+			static function ( $row ) {
+				if ( isset( $row['upsell_ids'] ) ) {
+					$row['upsell_ids'] = array_values( array_map( 'absint', $row['upsell_ids'] ) );
+				}
+				return $row;
+			}
+		);
+
+		$result = $this->import_relationship_updates( $csv, $batch_size );
+
+		$this->assertEmpty( $result['failed'], wp_json_encode( $result['failed'] ) );
+		$this->assertSame( array( $parent->get_id(), $variation->get_id(), $linked->get_id(), $group->get_id() ), $result['updated'] );
+		$this->assertSame( 'parent-after-import', wc_get_product( $parent->get_id() )->get_sku() );
+		$updated_variation = wc_get_product( $variation->get_id() );
+		$this->assertSame( 'variation-after-import', $updated_variation->get_sku() );
+		$this->assertSame( $parent->get_id(), $updated_variation->get_parent_id() );
+		$updated_group = wc_get_product( $group->get_id() );
+		$this->assertSame( array( $linked->get_id() ), $updated_group->get_children() );
+		$this->assertSame( array( $parent->get_id(), $linked->get_id() ), $updated_group->get_upsell_ids() );
+		$this->assertSame( array( $linked->get_id() ), $updated_group->get_cross_sell_ids() );
+	}
+
+	/**
+	 * @testdox An update skips an unresolved relationship field and saves the rest of the row.
+	 * @testWith ["grouped_products", "children", "Grouped products"]
+	 *           ["upsell_ids", "upsell_ids", "Upsells"]
+	 *           ["cross_sell_ids", "cross_sell_ids", "Cross-sells"]
+	 *
+	 * @param string $column   Mapped CSV column.
+	 * @param string $property Product property written by that column.
+	 * @param string $label    Column label shown in the warning.
+	 */
+	public function test_import_preserves_relationships_when_linked_sku_is_unresolved( string $column, string $property, string $label ): void {
+		$original    = WC_Helper_Product::create_simple_product();
+		$replacement = WC_Helper_Product::create_simple_product();
+		$group       = new WC_Product_Grouped();
+		$group->set_props( array( $property => array( $original->get_id() ) ) );
+		$group->save();
+
+		$csv    = "id,sku,{$column}\n"
+			. "{$group->get_id()},group-after-import,\"id:{$replacement->get_id()},later-sku\"\n"
+			. "{$replacement->get_id()},later-sku,\n";
+		$result = $this->import_relationship_updates( $csv );
+
+		$this->assertEmpty( $result['failed'] );
+		$this->assertSame( array( $group->get_id(), $replacement->get_id() ), $result['updated'] );
+		$this->assertCount( 1, $result['warnings'] );
+		$this->assertStringContainsString( $label, $result['warnings'][0]->get_error_message() );
+		$this->assertStringContainsString( 'later-sku', $result['warnings'][0]->get_error_message() );
+		$this->assertSame( $replacement->get_id(), wc_get_product_id_by_sku( 'later-sku' ), 'An unresolved link must not take the SKU from the later row.' );
+		$updated_group = wc_get_product( $group->get_id() );
+		$this->assertInstanceOf( WC_Product_Grouped::class, $updated_group );
+		$this->assertSame( array( $original->get_id() ), $updated_group->{ 'get_' . $property }() );
+		$this->assertSame( 'group-after-import', $updated_group->get_sku() );
+	}
+
+	/**
+	 * @testdox An update saves a new price and keeps an existing upsell when its SKU is in the trash.
+	 */
+	public function test_import_updates_price_with_trashed_upsell_sku(): void {
+		$upsell = WC_Helper_Product::create_simple_product( true, array( 'sku' => 'trashed-upsell' ) );
+		$source = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'regular_price' => '10',
+				'upsell_ids'    => array( $upsell->get_id() ),
+			)
+		);
+		wp_trash_post( $upsell->get_id() );
+		$this->assertSame( ProductStatus::TRASH, get_post_status( $upsell->get_id() ) );
+
+		$cross_sell = WC_Helper_Product::create_simple_product();
+		$result     = $this->import_relationship_updates( "id,regular_price,upsell_ids,cross_sell_ids\n{$source->get_id()},20,trashed-upsell,id:{$cross_sell->get_id()}\n" );
+
+		$this->assertSame( array( $source->get_id() ), $result['updated'] );
+		$this->assertEmpty( $result['failed'] );
+		$this->assertCount( 1, $result['warnings'] );
+		$this->assertStringContainsString( 'trashed-upsell', $result['warnings'][0]->get_error_message() );
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'trashed-upsell' ), 'The import must not create a placeholder for the trashed SKU.' );
+		$updated_source = wc_get_product( $source->get_id() );
+		$this->assertInstanceOf( WC_Product_Simple::class, $updated_source );
+		$this->assertSame( array( $upsell->get_id() ), $updated_source->get_upsell_ids() );
+		$this->assertSame( array( $cross_sell->get_id() ), $updated_source->get_cross_sell_ids() );
+		$this->assertSame( '20', $updated_source->get_regular_price() );
+	}
+
+	/**
+	 * @testdox A parsed-data filter can supply a replacement for an unresolved relationship.
+	 */
+	public function test_import_respects_filtered_relationship(): void {
+		$source = WC_Helper_Product::create_simple_product();
+		$target = WC_Helper_Product::create_simple_product();
+		add_filter(
+			'woocommerce_product_importer_parsed_data',
+			static function ( $row ) use ( $target ) {
+				$row['upsell_ids'] = array( $target->get_id() );
+				return $row;
+			}
+		);
+
+		$result = $this->import_relationship_updates( "id,upsell_ids\n{$source->get_id()},missing-sku\n" );
+
+		$this->assertEmpty( $result['failed'] );
+		$this->assertSame( array( $source->get_id() ), $result['updated'] );
+		$this->assertSame( array( $target->get_id() ), wc_get_product( $source->get_id() )->get_upsell_ids() );
+	}
+
+	/**
+	 * Import a fixture using the same file cursor as the AJAX controller.
+	 *
+	 * @param string $csv        CSV contents with mapped column names.
+	 * @param int    $batch_size Rows per request, or -1 for an unlimited batch.
+	 * @return array Updated IDs, failed rows, and warnings.
+	 */
+	private function import_relationship_updates( string $csv, int $batch_size = -1 ): array {
+		$file = trailingslashit( get_temp_dir() ) . wp_generate_uuid4() . '.csv';
+		file_put_contents( $file, $csv ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Write a temporary CSV fixture.
+
+		try {
+			$results = array(
+				'updated'  => array(),
+				'failed'   => array(),
+				'warnings' => array(),
+			);
+			$args    = array(
+				'parse'            => true,
+				'update_existing'  => true,
+				'prevent_timeouts' => false,
+				'lines'            => $batch_size,
+				'start_pos'        => 0,
+			);
+			do {
+				$sut    = new WC_Product_CSV_Importer( $file, $args );
+				$result = $sut->import();
+				foreach ( $results as $key => $items ) {
+					$results[ $key ] = array_merge( $items, $result[ $key ] ?? array() );
+				}
+				$this->assertGreaterThan( $args['start_pos'], $sut->get_file_position(), 'Every batch should consume more of the CSV.' );
+				$args['start_pos'] = $sut->get_file_position();
+			} while ( 100 !== $sut->get_percent_complete() );
+
+			return $results;
+		} finally {
+			wp_delete_file( $file );
+		}
+	}
+
 	/**
 	 * @testdox CSV imports update MPNs and allow zero and explicit clearing.
 	 * @testWith ["simple", "PART-IMPORTED"]
@@ -2780,7 +2954,8 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
 		$csv    = "ID,Type,SKU,Name,Parent\n" .
 			"{$first->get_id()},variation,{$first->get_sku()},Unknown parent,IMPORT-40274-NO-SUCH-PARENT\n" .
 			"{$second->get_id()},variation,{$second->get_sku()},Simple parent,IMPORT-40274-SIMPLE-PARENT\n" .
-			"{$third->get_id()},variation,{$third->get_sku()},Importing parent,id:{$placeholder->get_id()}\n";
+			"{$third->get_id()},variation,{$third->get_sku()},Importing parent,id:{$placeholder->get_id()}\n" .
+			",variation,IMPORT-40274-NEW-VARIATION,New variation,IMPORT-40274-NO-SUCH-PARENT\n";

 		$data = $this->import_csv_rows(
 			$csv,
@@ -2795,9 +2970,11 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {

 		$this->assertEmpty( $data['updated'], 'No variation may be moved' );
 		$this->assertCount( 3, $data['failed'], 'Each row fails' );
-		$this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][0]->get_error_code(), 'An unknown parent SKU, resolved to a placeholder, is refused' );
+		$this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][0]->get_error_code(), 'An unknown parent SKU is refused' );
 		$this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][1]->get_error_code(), 'A simple parent is refused' );
 		$this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][2]->get_error_code(), 'A variable parent still being imported is refused' );
+		$this->assertCount( 1, $data['skipped'], 'A new variation with an unresolved parent SKU is skipped.' );
+		$this->assertStringContainsString( 'IMPORT-40274-NO-SUCH-PARENT', $data['skipped'][0]->get_error_message(), 'The reason names the unresolved SKU.' );
 		$this->assertSame( $parent->get_id(), wc_get_product( $first->get_id() )->get_parent_id(), 'The first variation keeps its parent' );
 		$this->assertSame( $parent->get_id(), wc_get_product( $second->get_id() )->get_parent_id(), 'The second variation keeps its parent' );
 		$this->assertSame( $parent->get_id(), wc_get_product( $third->get_id() )->get_parent_id(), 'The third variation keeps its parent' );