Commit f0884634f73 for woocommerce

commit f0884634f7334f5e51c72d5b2cb40a8be4bec29e
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date:   Mon Nov 2 23:44:23 2026 +0100

    Send each UCP catalog variant's GTIN as a barcode (#70351)

    * Send each UCP catalog variant's GTIN as a barcode

    UCP variants carry product identifiers in barcodes, but the mapper sent
    none, so agents could not match a product by GTIN even when the store set
    one. Normalize and validate the GTIN the way the product structured data
    does, and send a variation's own GTIN only.

    * Cast the normalized GTIN to string for PHPStan

    * Validate UCP barcodes with the structured data GTIN helpers

    So the catalog and the product structured data cannot disagree on a GTIN.

diff --git a/plugins/woocommerce/changelog/add-ucp-variant-barcodes b/plugins/woocommerce/changelog/add-ucp-variant-barcodes
new file mode 100644
index 00000000000..9d8a8135e7a
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ucp-variant-barcodes
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+UCP catalog: send each variant's GTIN as a barcode.
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Mapping/UcpProductMapper.php b/plugins/woocommerce/src/Internal/ProductFeed/Mapping/UcpProductMapper.php
index 4686fb24d64..a930044991b 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Mapping/UcpProductMapper.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Mapping/UcpProductMapper.php
@@ -501,7 +501,7 @@ class UcpProductMapper implements ProductShapeMapperInterface {
 	 */
 	private function build_variants( \WC_Product $product, array $item, string $currency, array $required_variation_ids, bool $only_required, ?array $candidates ): array {
 		if ( ! $product instanceof \WC_Product_Variable ) {
-			return array( $this->build_simple_variant( $item, $currency ) );
+			return array( $this->build_simple_variant( $product, $item, $currency ) );
 		}

 		$children = array_map( 'intval', null === $candidates ? $product->get_visible_children() : $candidates );
@@ -557,7 +557,7 @@ class UcpProductMapper implements ProductShapeMapperInterface {
 				continue;
 			}

-			$variants[] = $this->build_variation_variant( $this->item( $variation ), $options[ (int) $variation_id ] ?? array(), $currency );
+			$variants[] = $this->build_variation_variant( $variation, $this->item( $variation ), $options[ (int) $variation_id ] ?? array(), $currency );
 		}

 		return $variants;
@@ -585,11 +585,12 @@ class UcpProductMapper implements ProductShapeMapperInterface {
 	/**
 	 * Build a variant entry from a simple product's item response.
 	 *
-	 * @param array  $item Store API item response.
-	 * @param string $currency ISO 4217 currency code.
+	 * @param \WC_Product $product The product.
+	 * @param array       $item Store API item response.
+	 * @param string      $currency ISO 4217 currency code.
 	 * @return array
 	 */
-	private function build_simple_variant( array $item, string $currency ): array {
+	private function build_simple_variant( \WC_Product $product, array $item, string $currency ): array {
 		$variant = array(
 			'id'           => (string) $item['id'],
 			'title'        => (string) $item['name'],
@@ -603,6 +604,11 @@ class UcpProductMapper implements ProductShapeMapperInterface {
 			$variant['sku'] = $sku;
 		}

+		$barcodes = $this->build_barcodes( $product );
+		if ( ! empty( $barcodes ) ) {
+			$variant['barcodes'] = $barcodes;
+		}
+
 		$list_price = $this->build_variant_list_price( $item['prices'], $currency );
 		if ( null !== $list_price ) {
 			$variant['list_price'] = $list_price;
@@ -614,12 +620,13 @@ class UcpProductMapper implements ProductShapeMapperInterface {
 	/**
 	 * Build a variant entry from a variation's item response.
 	 *
-	 * @param array  $item Store API item response of the variation.
-	 * @param array  $options Selected options, each `{ name, label }`.
-	 * @param string $currency ISO 4217 currency code.
+	 * @param \WC_Product_Variation $variation The variation.
+	 * @param array                 $item Store API item response of the variation.
+	 * @param array                 $options Selected options, each `{ name, label }`.
+	 * @param string                $currency ISO 4217 currency code.
 	 * @return array
 	 */
-	private function build_variation_variant( array $item, array $options, string $currency ): array {
+	private function build_variation_variant( \WC_Product_Variation $variation, array $item, array $options, string $currency ): array {
 		$labels = array_column( $options, 'label' );

 		$variant = array(
@@ -635,6 +642,11 @@ class UcpProductMapper implements ProductShapeMapperInterface {
 			$variant['sku'] = $sku;
 		}

+		$barcodes = $this->build_barcodes( $variation );
+		if ( ! empty( $barcodes ) ) {
+			$variant['barcodes'] = $barcodes;
+		}
+
 		$list_price = $this->build_variant_list_price( $item['prices'], $currency );
 		if ( null !== $list_price ) {
 			$variant['list_price'] = $list_price;
@@ -652,6 +664,33 @@ class UcpProductMapper implements ProductShapeMapperInterface {
 		return $variant;
 	}

+	/**
+	 * Build a variant's barcodes from its GTIN, normalized and validated by the product structured data,
+	 * so the catalog and the structured data never disagree on a GTIN.
+	 *
+	 * A variation's GTIN is its own: the parent's identifies a different item.
+	 *
+	 * @param \WC_Product $product The product or variation.
+	 * @return array Empty, or one `{ type, value }` barcode.
+	 */
+	private function build_barcodes( \WC_Product $product ): array {
+		$structured_data = WC()->structured_data;
+		if ( ! $structured_data instanceof \WC_Structured_Data ) {
+			return array();
+		}
+		$gtin = (string) $structured_data->prepare_gtin( (string) $product->get_global_unique_id() );
+		if ( ! $structured_data->is_valid_gtin( $gtin ) ) {
+			return array();
+		}
+
+		return array(
+			array(
+				'type'  => 'GTIN',
+				'value' => $gtin,
+			),
+		);
+	}
+
 	/**
 	 * Build a variant's list price when it is genuinely discounted.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/UcpProductMapperTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/UcpProductMapperTest.php
index 9c64fce1939..6306d2e30d5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/UcpProductMapperTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/UcpProductMapperTest.php
@@ -322,4 +322,52 @@ class UcpProductMapperTest extends \WC_Unit_Test_Case {
 			$mapped['variants'][0]['options']
 		);
 	}
+
+	/**
+	 * Agents match offers across stores by GTIN, so a valid one must reach the
+	 * variant, normalized to digits, and an invalid one must not.
+	 *
+	 * @testdox A valid GTIN should be sent as a variant barcode and an invalid one should be left out.
+	 */
+	public function test_valid_gtin_is_sent_as_a_variant_barcode(): void {
+		$mapper = new UcpProductMapper( 'USD' );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_global_unique_id( '4006-3813-3393-1' );
+		$product->save();
+
+		$this->assertSame(
+			array(
+				array(
+					'type'  => 'GTIN',
+					'value' => '4006381333931',
+				),
+			),
+			$mapper->map_product( $product )['variants'][0]['barcodes']
+		);
+
+		$product->set_global_unique_id( '12345' );
+		$product->save();
+
+		$this->assertArrayNotHasKey( 'barcodes', $mapper->map_product( $product )['variants'][0] );
+	}
+
+	/**
+	 * @testdox Each variation should send its own GTIN, not the parent's.
+	 */
+	public function test_variation_sends_its_own_gtin(): void {
+		$variable = WC_Helper_Product::create_variation_product();
+		$variable->set_global_unique_id( '96385074' );
+		$variable->save();
+
+		$children = $variable->get_children();
+		$own      = wc_get_product( $children[0] );
+		$own->set_global_unique_id( '4006381333931' );
+		$own->save();
+
+		$variants = array_column( ( new UcpProductMapper( 'USD' ) )->map_product( wc_get_product( $variable->get_id() ) )['variants'], null, 'id' );
+
+		$this->assertSame( '4006381333931', $variants[ (string) $children[0] ]['barcodes'][0]['value'] );
+		$this->assertArrayNotHasKey( 'barcodes', $variants[ (string) $children[1] ] );
+	}
 }