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' );