Commit 5d1d85e0e73 for woocommerce
commit 5d1d85e0e73e5df1a6a6298dba5813fe7d97c157
Author: Brian <brian@brianhaas.li>
Date: Mon Oct 26 18:28:33 2026 +0100
Add MPN (manufacturer part / product number) as a valid product field
---------
Co-authored-by: Lucio Giannotta <lucio.giannotta@a8c.com>
diff --git a/docs/apis/rest-api/v2/products.mdx b/docs/apis/rest-api/v2/products.mdx
index 085595cc1c8..6d5990b3d25 100644
--- a/docs/apis/rest-api/v2/products.mdx
+++ b/docs/apis/rest-api/v2/products.mdx
@@ -27,6 +27,7 @@ The products API allows you to create, view, update, and delete individual, or a
| `description` | string | Product description. | | |
| `short_description` | string | Product short description. | | |
| `sku` | string | Unique identifier. | | |
+| `mpn` | string | Manufacturer part number. | | |
| `price` | string | Current product price. `READ-ONLY` | | |
| `regular_price` | string | Product regular price. | | |
| `sale_price` | string | Product sale price. | | |
diff --git a/docs/apis/rest-api/v3/product-variations.mdx b/docs/apis/rest-api/v3/product-variations.mdx
index 62b33dc6a43..38ef4e894fd 100644
--- a/docs/apis/rest-api/v3/product-variations.mdx
+++ b/docs/apis/rest-api/v3/product-variations.mdx
@@ -22,6 +22,7 @@ The product variations API allows you to create, view, update, and delete indivi
| `description` | string | Variation description. |
| `permalink` | string | Variation URL. `READ-ONLY` |
| `sku` | string | Unique identifier. |
+| `mpn` | string | Manufacturer part number. |
| `global_unique_id` | string | GTIN, UPC, EAN or ISBN. A global unique identifier for the variation. |
| `customs_commodity_code` | string, null | Customs code containing 6–14 digits after spaces and punctuation are removed. The default `view` context returns the parent value when the variation has none; `context=edit` returns the stored value, `null` when inherited. |
| `customs_country_of_origin` | string, null | Two-letter ISO country of origin code. The default `view` context returns the parent value when the variation has none; `context=edit` returns the stored value, `null` when inherited. |
@@ -687,6 +688,7 @@ woocommerce.get("products/22/variations").parsed_response
| `include_status` | string | Limit result set to product variations with any of the specified statuses. Multiple statuses can be provided as a comma-separated list. Takes precedence over the `status` parameter. Options: `any`, `future`, `trash`, `draft`, `pending`, `private`, and `publish`. |
| `exclude_status` | string | Exclude product variations from result set with any of the specified statuses. Multiple statuses can be provided as a comma-separated list. Takes precedence over the `include_status` parameter. Options: `future`, `trash`, `draft`, `pending`, `private`, and `publish`. |
| `sku` | string | Limit result set to products with a specific SKU. |
+| `mpn` | string | Limit results to exact MPN matches. Separate multiple values with commas. |
| `tax_class` | string | Limit result set to products with a specific tax class. Default options: `standard`, `reduced-rate` and `zero-rate`. |
| `on_sale` | boolean | Limit result set to products on sale. |
| `min_price` | string | Limit result set to products based on a minimum price. |
@@ -1542,4 +1544,4 @@ woocommerce.post("products/22/variations/batch", data).parsed_response
```
</TabItem>
-</Tabs>
\ No newline at end of file
+</Tabs>
diff --git a/docs/apis/rest-api/v3/products.mdx b/docs/apis/rest-api/v3/products.mdx
index f9e238a7d08..d70b26a0a61 100644
--- a/docs/apis/rest-api/v3/products.mdx
+++ b/docs/apis/rest-api/v3/products.mdx
@@ -29,6 +29,7 @@ The products API allows you to create, view, update, and delete individual, or a
| `description` | string | Product description. |
| `short_description` | string | Product short description. |
| `sku` | string | Unique identifier. |
+| `mpn` | string | Manufacturer part number. |
| `global_unique_id` | string | GTIN, UPC, EAN or ISBN - a unique identifier for each distinct product and service that can be purchased. |
| `customs_commodity_code` | string, null | Optional customs code containing 6–14 digits after spaces and punctuation are removed. Letters are rejected. |
| `customs_country_of_origin` | string, null | Optional two-letter ISO country of origin code; normalized to uppercase. |
@@ -1507,7 +1508,7 @@ woocommerce.get("products").parsed_response
| `page` | integer | Current page of the collection. Default is `1`. |
| `per_page` | integer | Maximum number of items to be returned in result set. Default is `10`. |
| `search` | string | Limit results to those matching a string. |
-| `search_fields` | array | Fields to search when used with `search` parameter. All search tokens must be found across any of the specified fields. Takes precedence over other search parameters. If empty, uses default `search` behavior. Options: `name`, `sku`, `global_unique_id`, `description`, and `short_description`. |
+| `search_fields` | array | Fields to search when used with `search` parameter. All search tokens must be found across any of the specified fields. Takes precedence over other search parameters. If empty, uses default `search` behavior. Options: `name`, `sku`, `global_unique_id`, `mpn`, `description`, and `short_description`. |
| `after` | string | Limit response to resources published after a given ISO8601 compliant date. |
| `before` | string | Limit response to resources published before a given ISO8601 compliant date. |
| `modified_after` | string | Limit response to resources modified after a given ISO8601 compliant date. |
@@ -1528,6 +1529,8 @@ woocommerce.get("products").parsed_response
| `include_types` | string | Limit result set to products with any of the types. Multiple statuses can be provided as a comma-separated list. Takes precedence over the `type` parameter. Options: `simple`, `grouped`, `external` and `variable`. |
| `exclude_types` | string | Exclude products from result set with any of the specified types. Multiple statuses can be provided as a comma-separated list. Takes precedence over the `include_types` parameter. Options: `simple`, `grouped`, `external` and `variable`. |
| `sku` | string | Limit result set to products with a specific SKU. |
+| `mpn` | string | Limit results to exact MPN matches. Separate multiple values with commas. |
+| `search_mpn` | string | Limit results to partial MPN matches within the first 100 characters. Takes precedence over `mpn`; combines with name and SKU search filters. `search` with `search_fields` takes precedence over `search_mpn`. |
| `featured` | boolean | Limit result set to featured products. |
| `category` | string | Limit result set to products assigned a specific category ID. |
| `tag` | string | Limit result set to products assigned a specific tag ID. |
diff --git a/plugins/woocommerce/changelog/60286-mpn-feature b/plugins/woocommerce/changelog/60286-mpn-feature
new file mode 100644
index 00000000000..a6d74507659
--- /dev/null
+++ b/plugins/woocommerce/changelog/60286-mpn-feature
@@ -0,0 +1,4 @@
+Significance: major
+Type: add
+
+Introduce MPN field (manufacturer part / product number) field to WooCommerce
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
index 6142b6ef5f6..875b4d586db 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
@@ -73,6 +73,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
'short_description' => '',
'sku' => '',
'global_unique_id' => '',
+ 'mpn' => '',
'price' => '',
'regular_price' => '',
'sale_price' => '',
@@ -284,6 +285,17 @@ class WC_Product extends WC_Abstract_Legacy_Product {
return $this->get_prop( 'global_unique_id', $context );
}
+ /**
+ * Get MPN.
+ *
+ * @since 11.4.0
+ * @param string $context What the value is for. Valid values are view and edit.
+ * @return string
+ */
+ public function get_mpn( $context = 'view' ) {
+ return $this->get_prop( 'mpn', $context );
+ }
+
/**
* Gets the customs commodity code.
*
@@ -956,6 +968,17 @@ class WC_Product extends WC_Abstract_Legacy_Product {
$this->set_prop( 'global_unique_id', $global_unique_id );
}
+ /**
+ * Set MPN.
+ *
+ * @since 11.4.0
+ * @param string $mpn Product MPN.
+ * @return void
+ */
+ public function set_mpn( $mpn ) {
+ $this->set_prop( 'mpn', (string) $mpn );
+ }
+
/**
* Sets the customs commodity code.
*
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 3ec84e250f8..c19ad9ae2e9 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
@@ -779,6 +779,7 @@ class WC_Product_CSV_Importer_Controller {
__( 'Type', 'woocommerce' ) => 'type',
__( 'SKU', 'woocommerce' ) => 'sku',
__( 'GTIN, UPC, EAN, or ISBN', 'woocommerce' ) => 'global_unique_id',
+ __( 'MPN', 'woocommerce' ) => 'mpn',
__( 'Name', 'woocommerce' ) => 'name',
__( 'Published', 'woocommerce' ) => 'published',
__( 'Is featured?', 'woocommerce' ) => 'featured',
@@ -961,6 +962,7 @@ class WC_Product_CSV_Importer_Controller {
'type' => __( 'Type', 'woocommerce' ),
'sku' => __( 'SKU', 'woocommerce' ),
'global_unique_id' => __( 'GTIN, UPC, EAN, or ISBN', 'woocommerce' ),
+ 'mpn' => __( 'MPN', 'woocommerce' ),
'name' => __( 'Name', 'woocommerce' ),
'published' => __( 'Published', 'woocommerce' ),
'featured' => __( 'Is featured?', 'woocommerce' ),
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
index 2d2055a3724..6ff8d3b82f7 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
@@ -375,6 +375,7 @@ class WC_Meta_Box_Product_Data {
array(
'sku' => isset( $_POST['_sku'] ) ? wc_clean( wp_unslash( $_POST['_sku'] ) ) : null,
'global_unique_id' => isset( $_POST['_global_unique_id'] ) ? wc_clean( wp_unslash( $_POST['_global_unique_id'] ) ) : null,
+ 'mpn' => isset( $_POST['_mpn'] ) ? wc_clean( wp_unslash( $_POST['_mpn'] ) ) : null,
'purchase_note' => isset( $_POST['_purchase_note'] ) ? wp_kses_post( wp_unslash( $_POST['_purchase_note'] ) ) : '',
'downloadable' => isset( $_POST['_downloadable'] ),
'virtual' => isset( $_POST['_virtual'] ),
@@ -601,6 +602,7 @@ class WC_Meta_Box_Product_Data {
'attributes' => self::prepare_set_attributes( $parent->get_attributes(), 'attribute_', $i ),
'sku' => isset( $_POST['variable_sku'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_sku'][ $i ] ) ) : '',
'global_unique_id' => isset( $_POST['variable_global_unique_id'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_global_unique_id'][ $i ] ) ) : '',
+ 'mpn' => isset( $_POST['variable_mpn'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_mpn'][ $i ] ) ) : null,
'weight' => isset( $_POST['variable_weight'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_weight'][ $i ] ) ) : '',
'length' => isset( $_POST['variable_length'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_length'][ $i ] ) ) : '',
'width' => isset( $_POST['variable_width'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_width'][ $i ] ) ) : '',
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-inventory.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-inventory.php
index 32e1d64ba18..690e8f60992 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-inventory.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-inventory.php
@@ -41,6 +41,23 @@ if ( ! defined( 'ABSPATH' ) ) {
do_action( 'woocommerce_product_options_global_unique_id' );
+ woocommerce_wp_text_input(
+ array(
+ 'id' => '_mpn',
+ 'value' => $product_object->get_mpn( 'edit' ), // @phpstan-ignore variable.undefined (The including meta box supplies $product_object.)
+ 'label' => '<abbr title="' . esc_attr__( 'Manufacturer part number', 'woocommerce' ) . '">' . esc_html__( 'MPN', 'woocommerce' ) . '</abbr>',
+ 'desc_tip' => true,
+ 'description' => __( 'Enter the manufacturer part number for this product.', 'woocommerce' ),
+ )
+ );
+
+ /**
+ * Action hook after the MPN (Manufacturer part number) product option field.
+ *
+ * @since 11.4.0
+ */
+ do_action( 'woocommerce_product_options_mpn' );
+
?>
<div class="inline notice woocommerce-message show_if_variable">
<img class="info-icon" src="<?php echo esc_url( $info_img_url ); ?>" />
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
index 03819ce8c52..0c29d71f207 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
@@ -118,6 +118,18 @@ defined( 'ABSPATH' ) || exit;
'wrapper_class' => 'form-row',
)
);
+
+ woocommerce_wp_text_input(
+ array(
+ 'id' => "variable_mpn{$loop}", // @phpstan-ignore variable.undefined (The including meta box supplies $loop.)
+ 'name' => "variable_mpn[{$loop}]", // @phpstan-ignore variable.undefined (The including meta box supplies $loop.)
+ 'value' => $variation_object->get_mpn( 'edit' ),
+ 'label' => '<abbr title="' . esc_attr__( 'Manufacturer part number', 'woocommerce' ) . '">' . esc_html__( 'MPN', 'woocommerce' ) . '</abbr>',
+ 'desc_tip' => true,
+ 'description' => __( 'Enter the manufacturer part number for this variation.', 'woocommerce' ),
+ 'wrapper_class' => 'form-row',
+ )
+ );
?>
</div>
</div>
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index e558f0476e6..0d2d83c3094 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -374,6 +374,7 @@ class WC_Install {
),
'11.4.0' => array(
'wc_update_1140_re_enable_autoload_options',
+ 'wc_update_1140_add_mpn_to_product_lookup_table',
),
);
@@ -2059,6 +2060,7 @@ CREATE TABLE {$wpdb->prefix}wc_product_meta_lookup (
`product_id` bigint(20) NOT NULL,
`sku` varchar(100) NULL default '',
`global_unique_id` varchar(100) NULL default '',
+ `mpn` varchar(100) NOT NULL DEFAULT '',
`virtual` tinyint(1) NULL default 0,
`downloadable` tinyint(1) NULL default 0,
`min_price` decimal(19,4) NULL default NULL,
@@ -2079,7 +2081,8 @@ CREATE TABLE {$wpdb->prefix}wc_product_meta_lookup (
KEY `onsale` (`onsale`),
KEY min_max_price (`min_price`, `max_price`),
KEY sku (sku(50)),
- KEY global_unique_id (global_unique_id(50))
+ KEY global_unique_id (global_unique_id(50)),
+ KEY mpn (mpn(50))
) $collate;
CREATE TABLE {$wpdb->prefix}wc_tax_rate_classes (
tax_rate_class_id bigint(20) unsigned NOT NULL auto_increment,
diff --git a/plugins/woocommerce/includes/class-wc-structured-data.php b/plugins/woocommerce/includes/class-wc-structured-data.php
index 555a85e5c25..d9cca8be83d 100644
--- a/plugins/woocommerce/includes/class-wc-structured-data.php
+++ b/plugins/woocommerce/includes/class-wc-structured-data.php
@@ -261,6 +261,11 @@ class WC_Structured_Data {
$markup['gtin'] = $gtin;
}
+ $mpn = $product->get_mpn();
+ if ( '' !== $mpn ) {
+ $markup['mpn'] = $mpn;
+ }
+
if ( '' !== $product->get_price() ) {
// Assume prices will be valid until the end of next year, unless on sale and there is an end date.
$price_valid_until = gmdate( 'Y-12-31', time() + YEAR_IN_SECONDS );
@@ -291,6 +296,13 @@ class WC_Structured_Data {
} else {
unset( $markup['gtin'] );
}
+
+ $variation_mpn = $selected_variation->get_mpn();
+ if ( '' !== $variation_mpn ) {
+ $markup['mpn'] = $variation_mpn;
+ } else {
+ unset( $markup['mpn'] );
+ }
}
if ( $product->is_type( ProductType::VARIABLE ) && ! $selected_variation ) {
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 62ce80550a6..1cfa3cf2c3e 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -73,7 +73,7 @@ final class WooCommerce {
*
* @var string
*/
- public $db_version = '920';
+ public $db_version = '1140';
/**
* The single instance of the class.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index b9124804597..89a87788ab7 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -36,6 +36,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
'_visibility',
'_sku',
'_global_unique_id',
+ '_mpn',
'_customs_commodity_code',
'_customs_country_of_origin',
'_customs_description',
@@ -453,6 +454,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$meta_key_to_props = array(
'_sku' => 'sku',
'_global_unique_id' => 'global_unique_id',
+ '_mpn' => 'mpn',
'_regular_price' => 'regular_price',
'_sale_price' => 'sale_price',
'_price' => 'price',
@@ -742,6 +744,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$meta_key_to_props = array(
'_sku' => 'sku',
'_global_unique_id' => 'global_unique_id',
+ '_mpn' => 'mpn',
'_regular_price' => 'regular_price',
'_sale_price' => 'sale_price',
'_sale_price_dates_from' => 'date_on_sale_from',
@@ -975,7 +978,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
}
}
- $props_in_lookup_table = array( 'sku', 'global_unique_id', 'regular_price', 'sale_price', 'date_on_sale_from', 'date_on_sale_to', 'total_sales', 'average_rating', 'stock_quantity', 'stock_status', 'manage_stock', 'downloadable', 'virtual', 'tax_status', 'tax_class' );
+ $props_in_lookup_table = array( 'sku', 'global_unique_id', 'mpn', 'regular_price', 'sale_price', 'date_on_sale_from', 'date_on_sale_to', 'total_sales', 'average_rating', 'stock_quantity', 'stock_status', 'manage_stock', 'downloadable', 'virtual', 'tax_status', 'tax_class' );
if ( $this->cogs_feature_is_enabled() ) {
$props_in_lookup_table[] = 'cogs_value';
}
@@ -2675,6 +2678,11 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
if ( get_option( 'woocommerce_schema_version', 0 ) >= 920 ) {
$product_data['global_unique_id'] = get_post_meta( $id, '_global_unique_id', true );
}
+ if ( get_option( 'woocommerce_schema_version', 0 ) >= 1140 ) {
+ $raw_mpn = (string) get_post_meta( $id, '_mpn', true );
+ // Ensure we don't exceed lookup column size (varchar 100).
+ $product_data['mpn'] = mb_substr( $raw_mpn, 0, 100 );
+ }
return $product_data;
}
return array();
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
index 8c4f5c2f247..0526ba33f66 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
@@ -427,6 +427,7 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
'_downloadable' => 'downloadable',
'_sku' => 'sku',
'_global_unique_id' => 'global_unique_id',
+ '_mpn' => 'mpn',
'_stock' => 'stock_quantity',
'_weight' => 'weight',
'_length' => 'length',
diff --git a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
index 40707ff6db8..5d6274be041 100644
--- a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
+++ b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
@@ -137,6 +137,7 @@ class WC_Product_CSV_Exporter extends WC_CSV_Batch_Exporter {
'type' => __( 'Type', 'woocommerce' ),
'sku' => __( 'SKU', 'woocommerce' ),
'global_unique_id' => __( 'GTIN, UPC, EAN, or ISBN', 'woocommerce' ),
+ 'mpn' => __( 'MPN', 'woocommerce' ),
'name' => __( 'Name', 'woocommerce' ),
'published' => __( 'Published', 'woocommerce' ),
'featured' => __( 'Is featured?', 'woocommerce' ),
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
index d3cb94cccef..eba2d1add83 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
@@ -899,6 +899,9 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
case 'sku':
$base_data['sku'] = $product->get_sku( $context );
break;
+ case 'mpn':
+ $base_data['mpn'] = $product->get_mpn( $context );
+ break;
case 'price':
$base_data['price'] = $product->get_price( $context );
break;
@@ -1176,6 +1179,11 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
$product->set_sku( wc_clean( $request['sku'] ) );
}
+ // MPN.
+ if ( isset( $request['mpn'] ) ) {
+ $product->set_mpn( sanitize_text_field( $request['mpn'] ) );
+ }
+
// Attributes.
if ( isset( $request['attributes'] ) ) {
$attributes = array();
@@ -1939,6 +1947,11 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
'type' => 'string',
'context' => array( 'view', 'edit' ),
),
+ 'mpn' => array(
+ 'description' => __( 'Manufacturer part number.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ ),
'price' => array(
'description' => __( 'Current product price.', 'woocommerce' ),
'type' => 'string',
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 5f42650c3f2..231cbeaba3e 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -128,6 +128,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
'permalink' => $object->get_permalink(),
'sku' => $object->get_sku(),
'global_unique_id' => $object->get_global_unique_id(),
+ 'mpn' => $object->get_mpn(), // @phpstan-ignore method.notFound (This endpoint receives a variation; the generic WC_Data annotation is broader.)
'price' => $object->get_price(),
'regular_price' => $object->get_regular_price(),
'sale_price' => $object->get_sale_price(),
@@ -236,6 +237,11 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
$variation->set_global_unique_id( wc_clean( $request['global_unique_id'] ) );
}
+ // MPN.
+ if ( $variation instanceof WC_Product && isset( $request['mpn'] ) ) {
+ $variation->set_mpn( sanitize_text_field( $request['mpn'] ) );
+ }
+
// Thumbnail.
if ( isset( $request['image'] ) ) {
if ( is_array( $request['image'] ) ) {
@@ -621,6 +627,11 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
'type' => 'string',
'context' => array( 'view', 'edit' ),
),
+ 'mpn' => array(
+ 'description' => __( 'Manufacturer part number.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ ),
'price' => array(
'description' => __( 'Current variation price.', 'woocommerce' ),
'type' => 'string',
@@ -1097,6 +1108,19 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
);
}
+ // Filter by mpn.
+ if ( '' !== ( $request['mpn'] ?? '' ) ) {
+ $mpns = array_map( 'trim', explode( ',', $request['mpn'] ) );
+ $args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Match the full MPN; the lookup column only stores its first 100 characters.
+ $args,
+ array(
+ 'key' => '_mpn',
+ 'value' => $mpns,
+ 'compare' => 'IN',
+ )
+ );
+ }
+
// Filter by tax class.
if ( ! empty( $request['tax_class'] ) ) {
$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded to one parent's variations by post_parent, so this resolves through the postmeta post_id index.
@@ -1340,6 +1364,13 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
'validate_callback' => 'rest_validate_request_arg',
);
+ $params['mpn'] = array(
+ 'description' => __( 'Limit result set to product variations with specified MPN(s).', 'woocommerce' ),
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
$params['pos_products_only'] = array(
'description' => __( 'Limit result set to variations visible in Point of Sale.', 'woocommerce' ),
'type' => 'boolean',
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
index 077ed424283..9327a035c8e 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
@@ -49,6 +49,16 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
*/
private $search_sku_arg_value = '';
+ /**
+ * The value of the 'search_mpn' argument if present.
+ *
+ * See prepare_objects_query()
+ *
+ * @var string
+ * @since 11.4.0
+ */
+ private $search_mpn_arg_value = '';
+
/**
* If the 'search_name_or_sku' argument is present this will be set
* to an array of the (space-separated) tokens that form the argument value.
@@ -370,7 +380,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
'tokens' => $tokens,
);
- unset( $request['search'], $request['search_sku'], $request['sku'], $request['search_name_or_sku'], $args['s'] );
+ unset( $request['search'], $request['search_sku'], $request['sku'], $request['search_name_or_sku'], $request['search_mpn'], $request['mpn'], $args['s'] );
}
$search_name_or_sku_arg = $request['search_name_or_sku'] ?? '';
@@ -412,6 +422,14 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
}
}
+ // Do a partial match for MPN. Supersedes the 'mpn' argument, that does exact matching.
+ if ( '' !== ( $request['search_mpn'] ?? '' ) ) {
+ // Store this for use in the query clause filters.
+ $this->search_mpn_arg_value = $request['search_mpn'];
+
+ unset( $request['mpn'] );
+ }
+
if ( ! empty( $request['global_unique_id'] ) ) {
$global_unique_ids = array_map( 'trim', explode( ',', $request['global_unique_id'] ) );
$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
@@ -424,6 +442,18 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
);
}
+ if ( '' !== ( $request['mpn'] ?? '' ) ) {
+ $mpns = array_map( 'trim', explode( ',', $request['mpn'] ) );
+ $args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Match the full MPN; the lookup column only stores its first 100 characters.
+ $args,
+ array(
+ 'key' => '_mpn',
+ 'value' => $mpns,
+ 'compare' => 'IN',
+ )
+ );
+ }
+
// Filter by tax class.
if ( ! empty( $request['tax_class'] ) ) {
$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- wc_product_meta_lookup.tax_class is unindexed and holds NULL where _tax_class is unset, so it is neither faster nor equivalent.
@@ -476,7 +506,14 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
}
// Force the post_type argument, since it's not a user input variable.
- if ( ! empty( $request['sku'] ) || ! empty( $request['search_sku'] ) || $this->search_name_or_sku_tokens || $this->search_fields_tokens ) {
+ if (
+ ! empty( $request['sku'] )
+ || ! empty( $request['search_sku'] )
+ || '' !== ( $request['mpn'] ?? '' )
+ || '' !== ( $request['search_mpn'] ?? '' )
+ || $this->search_name_or_sku_tokens
+ || $this->search_fields_tokens
+ ) {
$args['post_type'] = array( 'product', 'product_variation' );
} else {
$args['post_type'] = $this->post_type;
@@ -513,7 +550,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
* @return array
*/
protected function get_objects( $query_args ) {
- $add_search_criteria = $this->search_sku_arg_value || $this->search_name_or_sku_tokens || $this->search_fields_tokens;
+ $add_search_criteria = $this->search_sku_arg_value || '' !== $this->search_mpn_arg_value || $this->search_name_or_sku_tokens || $this->search_fields_tokens;
// Add filters for search criteria in product postmeta via the lookup table.
if ( $add_search_criteria ) {
@@ -534,6 +571,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
remove_filter( 'posts_where', array( $this, 'add_search_criteria_to_wp_query_where' ) );
$this->search_sku_arg_value = '';
+ $this->search_mpn_arg_value = '';
$this->search_name_or_sku_tokens = null;
$this->search_fields_tokens = null;
}
@@ -569,6 +607,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
// Only join if we need meta table search.
if ( ! $this->search_fields_tokens &&
! $this->search_sku_arg_value &&
+ '' === $this->search_mpn_arg_value &&
! ( $this->search_name_or_sku_tokens && wc_product_sku_enabled() ) ) {
return $join;
}
@@ -605,6 +644,11 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
$like_search = '%' . $wpdb->esc_like( $this->search_sku_arg_value ) . '%';
$where .= ' AND ' . $wpdb->prepare( '(wc_product_meta_lookup.sku LIKE %s)', $like_search );
}
+
+ if ( '' !== $this->search_mpn_arg_value ) {
+ $like_search = '%' . $wpdb->esc_like( $this->search_mpn_arg_value ) . '%';
+ $where .= ' AND ' . $wpdb->prepare( '(wc_product_meta_lookup.mpn LIKE %s)', $like_search );
+ }
return $where;
}
@@ -626,6 +670,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
'name' => "{$wpdb->posts}.post_title",
'sku' => 'wc_product_meta_lookup.sku',
'global_unique_id' => 'wc_product_meta_lookup.global_unique_id',
+ 'mpn' => 'wc_product_meta_lookup.mpn',
'description' => "{$wpdb->posts}.post_content",
'short_description' => "{$wpdb->posts}.post_excerpt",
);
@@ -855,6 +900,11 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
$product->set_global_unique_id( wc_clean( $request['global_unique_id'] ) );
}
+ // MPN.
+ if ( isset( $request['mpn'] ) ) {
+ $product->set_mpn( sanitize_text_field( $request['mpn'] ) );
+ }
+
// Attributes.
if ( isset( $request['attributes'] ) ) {
$attributes = array();
@@ -1290,6 +1340,11 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
'type' => 'string',
'context' => array( 'view', 'edit' ),
),
+ 'mpn' => array(
+ 'description' => __( 'Manufacturer part number.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ ),
'price' => array(
'description' => __( 'Current product price.', 'woocommerce' ),
'type' => 'string',
@@ -1913,6 +1968,13 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
'validate_callback' => 'rest_validate_request_arg',
);
+ $params['search_mpn'] = array(
+ 'description' => __( "Limit results to those with an MPN that partially matches a string. This argument takes precedence over 'mpn'.", 'woocommerce' ),
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
$params['search_name_or_sku'] = array(
'description' => __( "Limit results to those with a name or SKU that partial matches a string. This argument takes precedence over 'search', 'sku' and 'search_sku'.", 'woocommerce' ),
'type' => 'string',
@@ -1920,13 +1982,13 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
'validate_callback' => 'rest_validate_request_arg',
);
- $search_fields_enum = array( 'name', 'global_unique_id', 'description', 'short_description' );
+ $search_fields_enum = array( 'name', 'global_unique_id', 'mpn', 'description', 'short_description' );
if ( wc_product_sku_enabled() ) {
$search_fields_enum[] = 'sku';
}
$params['search_fields'] = array(
- 'description' => __( 'Limit search to specific fields when used with search parameter. Available fields: name, sku, global_unique_id, description, short_description. This argument takes precedence over all other search parameters.', 'woocommerce' ),
+ 'description' => __( 'Limit search to specific fields when used with search parameter. Available fields: name, sku, global_unique_id, mpn, description, short_description. This argument takes precedence over all other search parameters.', 'woocommerce' ),
'type' => 'array',
'items' => array(
'type' => 'string',
@@ -1995,6 +2057,13 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
'validate_callback' => 'rest_validate_request_arg',
);
+ $params['mpn'] = array(
+ 'description' => __( 'Limit result set to products with specific MPN(s). Use commas to separate.', 'woocommerce' ),
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
$params['pos_products_only'] = array(
'description' => __( 'Limit result set to products visible in Point of Sale.', 'woocommerce' ),
'type' => 'boolean',
@@ -2103,6 +2172,10 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
$data['global_unique_id'] = $product->get_global_unique_id( $context );
}
+ if ( in_array( 'mpn', $fields, true ) ) {
+ $data['mpn'] = $product->get_mpn( $context );
+ }
+
foreach ( CustomsDataValidator::FIELDS as $property ) {
if ( in_array( $property, $fields, true ) ) {
$data[ $property ] = $product->{ 'get_' . $property }( $context );
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index a910f5f5b60..891d1675208 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -2251,6 +2251,10 @@ function wc_update_product_lookup_tables() {
'tax_status', // When last column is updated, woocommerce_product_lookup_table_is_generating is updated.
);
+ if ( get_option( 'woocommerce_schema_version', 0 ) >= 1140 ) {
+ array_splice( $columns, 4, 0, array( 'mpn' ) );
+ }
+
foreach ( $columns as $index => $column ) {
if ( 'average_rating' === $column && ! $reviews_enabled ) {
continue;
@@ -2351,6 +2355,13 @@ function wc_update_product_lookup_tables_column( $column ) {
"
);
break;
+ case 'mpn':
+ $wpdb->query(
+ "UPDATE {$wpdb->wc_product_meta_lookup} lookup_table
+ LEFT JOIN {$wpdb->postmeta} meta ON lookup_table.product_id = meta.post_id AND meta.meta_key = '_mpn'
+ SET lookup_table.mpn = LEFT( COALESCE( meta.meta_value, '' ), 100 )"
+ );
+ break;
case 'sku':
case 'global_unique_id':
case 'stock_status':
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 9cf6434fbe0..cc134a141ff 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -4244,3 +4244,20 @@ function wc_update_1140_re_enable_autoload_options(): void {
)
);
}
+
+/**
+ * Add the MPN lookup column and index for existing stores.
+ *
+ * @since 11.4.0
+ */
+function wc_update_1140_add_mpn_to_product_lookup_table(): void {
+ global $wpdb;
+
+ if ( ! $wpdb->get_var( "SHOW COLUMNS FROM {$wpdb->wc_product_meta_lookup} LIKE 'mpn'" ) ) {
+ $wpdb->query( "ALTER TABLE {$wpdb->wc_product_meta_lookup} ADD COLUMN mpn varchar(100) NOT NULL DEFAULT '' AFTER global_unique_id" );
+ }
+
+ if ( ! $wpdb->get_var( "SHOW INDEX FROM {$wpdb->wc_product_meta_lookup} WHERE Key_name = 'mpn'" ) ) {
+ $wpdb->query( "ALTER TABLE {$wpdb->wc_product_meta_lookup} ADD INDEX mpn (mpn(50))" );
+ }
+}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/products.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/products.php
index 29f0a8bf189..f8ffba1e2d1 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/products.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/products.php
@@ -532,7 +532,7 @@ class Products_API_V2 extends WC_REST_Unit_Test_Case {
}
/**
- * Test product schema.
+ * @testdox The product schema includes MPN.
*
* @since 3.0.0
*/
@@ -543,6 +543,7 @@ class Products_API_V2 extends WC_REST_Unit_Test_Case {
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$properties = $data['schema']['properties'];
- $this->assertEquals( 65, count( $properties ) );
+ $this->assertEquals( 66, count( $properties ) );
+ $this->assertArrayHasKey( 'mpn', $properties );
}
}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
index 9723081bf16..434605bbebe 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
@@ -389,7 +389,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
}
/**
- * Test variation schema.
+ * @testdox The variation schema includes MPN.
*
* @since 3.5.0
*/
@@ -401,7 +401,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
$data = $response->get_data();
$properties = $data['schema']['properties'];
- $this->assertEquals( 45, count( $properties ) );
+ $this->assertEquals( 46, count( $properties ) );
$this->assertArrayHasKey( 'id', $properties );
$this->assertArrayHasKey( 'date_created', $properties );
$this->assertArrayHasKey( 'date_modified', $properties );
@@ -409,6 +409,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
$this->assertArrayHasKey( 'permalink', $properties );
$this->assertArrayHasKey( 'sku', $properties );
$this->assertArrayHasKey( 'global_unique_id', $properties );
+ $this->assertArrayHasKey( 'mpn', $properties );
$this->assertArrayHasKey( 'price', $properties );
$this->assertArrayHasKey( 'regular_price', $properties );
$this->assertArrayHasKey( 'sale_price', $properties );
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php
index b6338eb04f1..91c33d348f4 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php
@@ -646,7 +646,7 @@ class WC_Tests_API_Product extends WC_REST_Unit_Test_Case {
}
/**
- * Test product schema.
+ * @testdox The product schema includes MPN.
*
* @since 3.5.0
*/
@@ -657,7 +657,8 @@ class WC_Tests_API_Product extends WC_REST_Unit_Test_Case {
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$properties = $data['schema']['properties'];
- $this->assertEquals( 75, count( $properties ) );
+ $this->assertEquals( 76, count( $properties ) );
+ $this->assertArrayHasKey( 'mpn', $properties );
$this->assertArrayHasKey( 'customs_commodity_code', $properties );
$this->assertArrayHasKey( 'customs_country_of_origin', $properties );
$this->assertArrayHasKey( 'customs_description', $properties );
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 e7c1d84064b..41a8c81a8ec 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
@@ -23,6 +23,19 @@ class WC_Product_CSV_Importer_Controller_Test extends WC_Unit_Test_Case {
require_once $bootstrap->plugin_dir . '/includes/admin/importers/class-wc-product-csv-importer-controller.php';
}
+ /**
+ * @testdox The CSV importer automatically maps the MPN column.
+ */
+ public function test_mpn_csv_header_mapping(): void {
+ $sut = new WC_Product_CSV_Importer_Controller();
+ $method = new ReflectionMethod( $sut, 'auto_map_columns' );
+ $method->setAccessible( true );
+
+ $mapping = $method->invoke( $sut, array( 'ID', 'MPN' ), false );
+
+ $this->assertSame( 'mpn', $mapping['MPN'] );
+ }
+
/**
* Tests that the automatic mapping is case insensitive so that columns can be matched more easily.
*/
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php
index 31329f6a6bf..4032850dd5d 100644
--- a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php
@@ -157,6 +157,61 @@ class WC_Meta_Box_Product_Data_Test extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox The classic editor saves MPNs, preserving omitted values and allowing explicit clearing.
+ * @testWith [null, "ORIGINAL"]
+ * ["0", "0"]
+ * ["PART / Blue", "PART / Blue"]
+ * ["", ""]
+ *
+ * @param string|null $submitted_mpn Submitted MPN, or null to omit the input.
+ * @param string $expected_mpn Expected saved value.
+ */
+ public function test_save_mpn( ?string $submitted_mpn, string $expected_mpn ): void {
+ $sut = new WC_Product_Simple();
+ $sut->set_mpn( 'ORIGINAL' );
+ $sut->save();
+
+ $_POST = array();
+ if ( null !== $submitted_mpn ) {
+ $_POST['_mpn'] = wp_slash( $submitted_mpn ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Supply Core's form input.
+ }
+
+ WC_Meta_Box_Product_Data::save( $sut->get_id(), get_post( $sut->get_id() ) );
+
+ $this->assertSame( $expected_mpn, wc_get_product( $sut->get_id() )->get_mpn( 'edit' ) );
+ }
+
+ /**
+ * @testdox Variation saves preserve omitted MPNs and allow explicit clearing.
+ * @testWith [null, "ORIGINAL"]
+ * ["0", "0"]
+ * ["PART / Blue", "PART / Blue"]
+ * ["", ""]
+ *
+ * @param string|null $submitted_mpn Submitted MPN, or null to omit the input.
+ * @param string $expected_mpn Expected saved value.
+ */
+ public function test_save_variation_mpn( ?string $submitted_mpn, string $expected_mpn ): void {
+ $parent = new WC_Product_Variable();
+ $parent->save();
+ $sut = new WC_Product_Variation();
+ $sut->set_parent_id( $parent->get_id() );
+ $sut->set_mpn( 'ORIGINAL' );
+ $sut->save();
+
+ $_POST = array( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Supply the variation editor request.
+ 'variable_post_id' => array( $sut->get_id() ),
+ );
+ if ( null !== $submitted_mpn ) {
+ $_POST['variable_mpn'] = array( wp_slash( $submitted_mpn ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Supply Core's form input.
+ }
+
+ WC_Meta_Box_Product_Data::save_variations( $parent->get_id(), get_post( $parent->get_id() ) );
+
+ $this->assertSame( $expected_mpn, wc_get_product( $sut->get_id() )->get_mpn( 'edit' ) );
+ }
+
/**
* Save a product through the public classic product data meta-box seam.
*
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
index 4a7adac5d07..8b245767fad 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
@@ -414,6 +414,22 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox Stores already on 11.3 schedule the MPN schema migration when upgrading.
+ */
+ public function test_mpn_migration_is_scheduled_after_113(): void {
+ $args = array( 'update_callback' => 'wc_update_1140_add_mpn_to_product_lookup_table' );
+ update_option( 'woocommerce_db_version', '11.3.0' );
+ $this->assertNull( WC()->queue()->get_next( 'woocommerce_run_update_callback', $args, 'woocommerce-db-updates' ) );
+
+ WC_Install::run_manual_database_update();
+
+ $this->assertNotNull(
+ WC()->queue()->get_next( 'woocommerce_run_update_callback', $args, 'woocommerce-db-updates' ),
+ 'Stores already on 11.3 must receive the MPN schema migration.'
+ );
+ }
+
/**
* Tests that the version check reaches the automatic database updater.
*
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-mpn-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-mpn-test.php
new file mode 100644
index 00000000000..b7719dd61d9
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-mpn-test.php
@@ -0,0 +1,361 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Enums\ProductStatus;
+
+/**
+ * Integration tests for manufacturer part numbers.
+ */
+class WC_Product_Mpn_Test extends WC_Unit_Test_Case {
+ /**
+ * Load migration functions, which are normally loaded only during updates.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ require_once WC_ABSPATH . 'includes/wc-update-functions.php';
+ }
+
+ /**
+ * Reset the REST server created for endpoint tests.
+ */
+ public function tearDown(): void {
+ $this->clear_rest_server();
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox MPNs survive product and variation saves, updates, and explicit clearing.
+ * @testWith ["simple"]
+ * ["variable"]
+ * ["variation"]
+ *
+ * @param string $type Product type.
+ */
+ public function test_mpn_persistence( string $type ): void {
+ $sut = $this->create_product( $type );
+ foreach ( array( 'AB-001 / Blue', '0', '' ) as $mpn ) {
+ $sut->set_mpn( $mpn );
+ $sut->save();
+ $sut = wc_get_product( $sut->get_id() );
+ $this->assertSame( $mpn, $sut->get_mpn( 'edit' ), 'Reloading must preserve the MPN, including zero and an explicit empty value.' );
+ $this->assertSame( $mpn, $this->get_lookup_mpn( $sut->get_id() ), 'The lookup value must follow CRUD updates.' );
+ }
+ }
+
+ /**
+ * @testdox A variation can share an MPN with another product without inheriting its parent's MPN.
+ */
+ public function test_mpn_is_not_unique_or_inherited(): void {
+ $sut = $this->create_product( ProductType::VARIATION );
+ $parent = wc_get_product( $sut->get_parent_id() );
+ $parent->set_mpn( 'SHARED-PART' );
+ $parent->save();
+ $this->assertSame( '', wc_get_product( $sut->get_id() )->get_mpn(), 'An unspecified variation MPN must remain empty.' );
+
+ $sut->set_mpn( 'SHARED-PART' );
+ $sut->save();
+ $this->assertSame( 'SHARED-PART', wc_get_product( $sut->get_id() )->get_mpn() );
+ }
+
+ /**
+ * @testdox Lookup regeneration restores truncated multibyte lookup values without changing the full MPN.
+ */
+ public function test_mpn_lookup_regeneration(): void {
+ global $wpdb;
+
+ $sut = $this->create_product( ProductType::SIMPLE );
+ $mpn = str_repeat( '部', 101 );
+ $sut->set_mpn( $mpn );
+ $sut->save();
+ $empty = $this->create_product( ProductType::SIMPLE );
+
+ $this->assertSame( str_repeat( '部', 100 ), $this->get_lookup_mpn( $sut->get_id() ) );
+ $wpdb->update(
+ $wpdb->wc_product_meta_lookup,
+ array( 'mpn' => 'STALE' ),
+ array( 'product_id' => $sut->get_id() )
+ );
+
+ wc_update_product_lookup_tables_column( 'mpn' );
+
+ $this->assertSame( $mpn, wc_get_product( $sut->get_id() )->get_mpn(), 'The lookup limit must not truncate the source value.' );
+ $this->assertSame( str_repeat( '部', 100 ), $this->get_lookup_mpn( $sut->get_id() ) );
+ $this->assertSame( '', $this->get_lookup_mpn( $empty->get_id() ), 'Absent metadata must produce an empty lookup value, never NULL.' );
+ }
+
+ /**
+ * @testdox The MPN schema upgrade leaves existing values untouched and subsequent saves populate the lookup.
+ */
+ public function test_mpn_schema_upgrade_does_not_backfill_existing_values(): void {
+ $sut = $this->create_product( ProductType::SIMPLE );
+ update_option( 'woocommerce_schema_version', '920' );
+ $sut->set_mpn( 'PRE-UPGRADE' );
+ $sut->save();
+ $this->assertSame( 'PRE-UPGRADE', wc_get_product( $sut->get_id() )->get_mpn() );
+ $this->assertSame( '', $this->get_lookup_mpn( $sut->get_id() ) );
+
+ update_option( 'woocommerce_schema_version', '1140' );
+ wc_update_1140_add_mpn_to_product_lookup_table();
+ wc_update_1140_add_mpn_to_product_lookup_table();
+ $this->assertSame( 'PRE-UPGRADE', wc_get_product( $sut->get_id() )->get_mpn() );
+ $this->assertSame( '', $this->get_lookup_mpn( $sut->get_id() ), 'The schema upgrade must not backfill pre-existing MPN metadata.' );
+
+ $sut->set_mpn( 'POST-UPGRADE' );
+ $sut->save();
+ $this->assertSame( 'POST-UPGRADE', wc_get_product( $sut->get_id() )->get_mpn() );
+ $this->assertSame( 'POST-UPGRADE', $this->get_lookup_mpn( $sut->get_id() ) );
+ }
+
+ /**
+ * @testdox REST product and variation endpoints persist MPNs and distinguish omission from explicit clearing.
+ * @testWith ["v2", "simple"]
+ * ["v3", "simple"]
+ * ["v3", "variation"]
+ *
+ * @param string $version REST API version.
+ * @param string $type Product type.
+ */
+ public function test_mpn_rest_round_trip( string $version, string $type ): void {
+ $server = $this->create_product_rest_server();
+ $cases = array(
+ 'update' => array( array( 'mpn' => 'PART-123' ), 'PART-123' ),
+ 'zero' => array( array( 'mpn' => '0' ), '0' ),
+ 'omitted' => array( array(), 'ORIGINAL' ),
+ 'clear' => array( array( 'mpn' => '' ), '' ),
+ );
+
+ foreach ( $cases as $case => list( $params, $expected_mpn ) ) {
+ $sut = $this->create_product( $type );
+ $sut->set_mpn( 'ORIGINAL' );
+ $sut->save();
+
+ $route = ProductType::VARIATION === $type ? "/wc/$version/products/{$sut->get_parent_id()}/variations/{$sut->get_id()}" : "/wc/$version/products/{$sut->get_id()}";
+ $request = new WP_REST_Request( 'PUT', $route );
+ $request->set_body_params( $params );
+ $this->assertSame( 200, $server->dispatch( $request )->get_status(), $case );
+ $response = $server->dispatch( new WP_REST_Request( 'GET', $route ) );
+ $this->assertSame( $expected_mpn, $response->get_data()['mpn'], $case );
+ $this->assertSame( $expected_mpn, wc_get_product( $sut->get_id() )->get_mpn( 'edit' ), $case );
+ }
+ }
+
+ /**
+ * @testdox MPN search returns the expected products and variations.
+ * @dataProvider mpn_search_provider
+ *
+ * @param array $params Search parameters.
+ * @param array $expected_keys Keys of the expected search fixtures.
+ */
+ public function test_mpn_rest_search( array $params, array $expected_keys ): void {
+ $server = $this->create_product_rest_server();
+ $products = $this->create_search_products();
+ $expected = array_map(
+ static function ( $key ) use ( $products ) {
+ return $products[ $key ]->get_id();
+ },
+ $expected_keys
+ );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v3/products' );
+ $request->set_query_params( $params );
+ $response = $server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( $expected, wp_list_pluck( $response->get_data(), 'id' ) );
+ }
+
+ /**
+ * Provide search parameters and expected fixture keys.
+ *
+ * @return array
+ */
+ public static function mpn_search_provider(): array {
+ return array(
+ 'exact MPN' => array( array( 'mpn' => 'PART_%_42' ), array( 'target' ) ),
+ 'literal wildcard symbols' => array( array( 'search_mpn' => '_%' ), array( 'target' ) ),
+ 'partial MPN and name' => array(
+ array(
+ 'search_mpn' => '_%',
+ 'search' => 'target',
+ ),
+ array( 'target' ),
+ ),
+ 'partial MPN and SKU' => array(
+ array(
+ 'search_mpn' => '_%',
+ 'search_sku' => 'mpn-target',
+ ),
+ array( 'target' ),
+ ),
+ 'MPN must also match SKU' => array(
+ array(
+ 'search_mpn' => 'missing',
+ 'search_sku' => 'mpn-target',
+ ),
+ array(),
+ ),
+ 'MPN must match name/SKU' => array(
+ array(
+ 'search_mpn' => 'missing',
+ 'search_name_or_sku' => 'target',
+ ),
+ array(),
+ ),
+ 'partial supersedes exact' => array(
+ array(
+ 'search_mpn' => '_%',
+ 'mpn' => 'missing',
+ ),
+ array( 'target' ),
+ ),
+ 'partial zero MPN' => array( array( 'search_mpn' => '0' ), array( 'variation' ) ),
+ 'exact zero MPN' => array( array( 'mpn' => '0' ), array( 'variation' ) ),
+ 'explicit search fields' => array(
+ array(
+ 'search' => '_%',
+ 'search_fields' => array( 'mpn' ),
+ 'search_mpn' => 'missing',
+ ),
+ array( 'target' ),
+ ),
+ 'search fields supersede exact MPN' => array(
+ array(
+ 'search' => '_%',
+ 'search_fields' => array( 'mpn' ),
+ 'mpn' => 'missing',
+ ),
+ array( 'target' ),
+ ),
+ 'name search supersedes exact MPN' => array(
+ array(
+ 'search' => 'other',
+ 'search_fields' => array( 'name' ),
+ 'mpn' => 'PART_%_42',
+ ),
+ array( 'other' ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox MPN search state does not affect a later request on the same REST server.
+ * @testWith [{"search_mpn": "_%"}]
+ * [{"search": "_%", "search_fields": ["mpn"]}]
+ *
+ * @param array $params First request's search parameters.
+ */
+ public function test_mpn_search_does_not_affect_later_requests( array $params ): void {
+ $server = $this->create_product_rest_server();
+ $products = $this->create_search_products();
+ $request = new WP_REST_Request( 'GET', '/wc/v3/products' );
+ $request->set_query_params( $params );
+ $response = $server->dispatch( $request );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( array( $products['target']->get_id() ), wp_list_pluck( $response->get_data(), 'id' ) );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v3/products' );
+ $request->set_param( 'include', array( $products['other']->get_id() ) );
+ $response = $server->dispatch( $request );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( array( $products['other']->get_id() ), wp_list_pluck( $response->get_data(), 'id' ) );
+ }
+
+ /**
+ * @testdox The variation endpoint accepts zero as an exact MPN filter.
+ */
+ public function test_variation_mpn_rest_search(): void {
+ $server = $this->create_product_rest_server();
+ $sut = $this->create_product( ProductType::VARIATION );
+ $sut->set_mpn( '0' );
+ $sut->save();
+
+ $request = new WP_REST_Request( 'GET', "/wc/v3/products/{$sut->get_parent_id()}/variations" );
+ $request->set_param( 'mpn', '0' );
+ $response = $server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( array( $sut->get_id() ), wp_list_pluck( $response->get_data(), 'id' ) );
+ }
+
+ /**
+ * Read the lookup copy independently of the product getter.
+ *
+ * @param int $product_id Product ID.
+ * @return string|null
+ */
+ private function get_lookup_mpn( int $product_id ): ?string {
+ global $wpdb;
+
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT mpn FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d",
+ $product_id
+ )
+ );
+
+ return $row->mpn ?? null;
+ }
+
+ /**
+ * Create products that distinguish literal MPN matches from wildcard matches.
+ *
+ * @return array<string, WC_Product>
+ */
+ private function create_search_products(): array {
+ $target = $this->create_product( ProductType::SIMPLE );
+ $target->set_name( 'MPN target' );
+ $target->set_sku( 'mpn-target-sku' );
+ $target->set_mpn( 'PART_%_42' );
+ $target->save();
+ $other = $this->create_product( ProductType::SIMPLE );
+ $other->set_name( 'MPN target other' );
+ $other->set_mpn( 'PART-XX42' );
+ $other->save();
+ $variation = $this->create_product( ProductType::VARIATION );
+ $variation->set_mpn( '0' );
+ $variation->save();
+
+ return array(
+ 'target' => $target,
+ 'other' => $other,
+ 'variation' => $variation,
+ );
+ }
+
+ /**
+ * Create a product with only the fixtures needed by MPN tests.
+ *
+ * @param string $type Product type.
+ * @return WC_Product
+ */
+ private function create_product( string $type ): WC_Product {
+ $class = WC_Product_Factory::get_product_classname( 0, $type );
+ $product = new $class();
+ $product->set_name( 'MPN test product' );
+ $product->set_status( ProductStatus::PUBLISH );
+ $product->set_regular_price( '10' );
+ if ( ProductType::VARIATION === $type ) {
+ $product->set_parent_id( $this->create_product( ProductType::VARIABLE )->get_id() );
+ }
+ $product->save();
+ return $product;
+ }
+
+ /**
+ * Register product routes and authenticate the test administrator.
+ *
+ * @return WP_REST_Server
+ */
+ private function create_product_rest_server(): WP_REST_Server {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ return $this->create_rest_server_with_routes(
+ array(
+ array( new WC_REST_Products_V2_Controller(), 'register_routes' ),
+ array( new WC_REST_Products_Controller(), 'register_routes' ),
+ array( new WC_REST_Product_Variations_Controller(), 'register_routes' ),
+ ),
+ true
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
index 1f378fc45cc..a3b4beb9047 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
@@ -349,6 +349,69 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
$this->assertEquals( $this->structured_data->prepare_gtin( '123.4e-5' ), '12345' );
}
+ /**
+ * @testdox Product schema includes zero MPNs, omits empty values, and preserves extension filters.
+ * @testWith ["0"]
+ * [""]
+ *
+ * @param string $mpn Manufacturer part number.
+ */
+ public function test_mpn_structured_data( string $mpn ): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_mpn( $mpn );
+ $sut = new WC_Structured_Data();
+ $sut->generate_product_data( $product );
+ $this->assertSame( '' === $mpn ? null : $mpn, $sut->get_data()[0]['mpn'] ?? null );
+
+ add_filter(
+ 'woocommerce_structured_data_product',
+ static function ( $markup ) {
+ $markup['mpn'] = 'EXTENSION-MPN';
+ return $markup;
+ }
+ );
+ $sut = new WC_Structured_Data();
+ $sut->generate_product_data( $product );
+ $this->assertSame( 'EXTENSION-MPN', $sut->get_data()[0]['mpn'] );
+ }
+
+ /**
+ * @testdox Product schema uses the selected variation's MPN without inheriting the parent's value.
+ * @testWith ["CHILD-PART", true, "CHILD-PART"]
+ * ["0", true, "0"]
+ * ["", true, null]
+ * ["CHILD-PART", false, "PARENT-PART"]
+ *
+ * @param string $variation_mpn Variation MPN.
+ * @param bool $selected Whether the request selects the variation.
+ * @param string|null $expected_mpn Expected MPN, or null when it should be omitted.
+ */
+ public function test_mpn_structured_data_for_selected_variation( string $variation_mpn, bool $selected, ?string $expected_mpn ): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $product->set_mpn( 'PARENT-PART' );
+ $product->save();
+ $variation = wc_get_product( wc_get_product_id_by_sku( 'DUMMY SKU VARIABLE HUGE RED 0' ) );
+ $variation->set_mpn( $variation_mpn );
+ $variation->save();
+ WC_Product_Variable::sync( $product->get_id() );
+
+ if ( $selected ) {
+ $_GET['attribute_pa_size'] = 'huge';
+ $_GET['attribute_pa_colour'] = 'red';
+ $_GET['attribute_pa_number'] = '0';
+ }
+ $sut = new WC_Structured_Data();
+ $sut->generate_product_data( wc_get_product( $product->get_id() ) );
+ $data = $sut->get_data()[0];
+
+ $this->assertSame( $selected ? 'Offer' : 'AggregateOffer', $data['offers'][0]['@type'] );
+ if ( null === $expected_mpn ) {
+ $this->assertArrayNotHasKey( 'mpn', $data );
+ } else {
+ $this->assertSame( $expected_mpn, $data['mpn'] );
+ }
+ }
+
/**
* Test simple product offer structured data includes offer-level price currency.
*
diff --git a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
index 8b605b4b3d7..5029849640e 100644
--- a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
+++ b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
@@ -78,6 +78,67 @@ class WC_Product_CSV_Exporter_Test extends \WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox CSV exports include the MPN header and saved product or variation value.
+ * @testWith ["simple"]
+ * ["variation"]
+ *
+ * @param string $type Product type.
+ */
+ public function test_mpn_csv_export( string $type ): void {
+ $class = WC_Product_Factory::get_product_classname( 0, $type );
+ $product = new $class();
+ $product->set_name( 'MPN test product' );
+ $product->set_status( ProductStatus::PUBLISH );
+ $product->set_regular_price( '10' );
+ if ( ProductType::VARIATION === $type ) {
+ $parent = new WC_Product_Variable();
+ $parent->save();
+ $product->set_parent_id( $parent->get_id() );
+ }
+ $product->set_mpn( 'PART-CSV' );
+ $product->save();
+
+ $csv = $this->export_mpn_csv( $product );
+ $rows = array_map( 'str_getcsv', explode( "\n", trim( $csv ) ) );
+
+ $this->assertSame( array( 'ID', 'MPN' ), $rows[0] );
+ $this->assertSame( array( (string) $product->get_id(), 'PART-CSV' ), $rows[1] );
+ }
+
+ /**
+ * Export the ID and MPN columns for a single product or variation.
+ *
+ * @param WC_Product $product Product to export.
+ * @return string
+ */
+ private function export_mpn_csv( WC_Product $product ): string {
+ $sut = new WC_Product_CSV_Exporter();
+ $sut->set_product_ids_to_export( array( $product->get_id() ) );
+ $sut->set_column_names(
+ array(
+ 'id' => 'ID',
+ 'mpn' => 'MPN',
+ )
+ );
+ $sut->set_columns_to_export( array( 'id', 'mpn' ) );
+ add_filter(
+ 'woocommerce_product_export_product_query_args',
+ static function ( $args ) use ( $product ) {
+ $args['type'] = $product->get_type();
+ return $args;
+ }
+ );
+ $sut->prepare_data_to_export();
+
+ $headers = new ReflectionMethod( $sut, 'export_column_headers' );
+ $headers->setAccessible( true );
+ $data = new ReflectionMethod( $sut, 'get_csv_data' );
+ $data->setAccessible( true );
+
+ return $headers->invoke( $sut ) . $data->invoke( $sut );
+ }
+
/**
* Write the current page the way file generation does, so the completion percentage includes its rows.
*
@@ -95,9 +156,10 @@ class WC_Product_CSV_Exporter_Test extends \WC_Unit_Test_Case {
* @testdox variations should use draft status from parent product
*/
public function test_get_column_value_published() {
- $product = WC_Helper_Product::create_variation_product();
+ $product = new WC_Product_Variable();
$product->set_status( ProductStatus::DRAFT );
- $product->save();
+ // Create variations after setting the parent status so cached variations also see the draft parent.
+ $product = WC_Helper_Product::create_variation_product( $product );
$this->product_ids = array_merge( array( $product->get_id() ), $product->get_children( 'edit' ) );
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 a05c2aec2b2..8ed928009ad 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,58 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
parent::tearDown();
}
+ /**
+ * @testdox CSV imports update MPNs and allow zero and explicit clearing.
+ * @testWith ["simple", "PART-IMPORTED"]
+ * ["simple", "0"]
+ * ["simple", ""]
+ * ["variation", "PART-IMPORTED"]
+ * ["variation", "0"]
+ * ["variation", ""]
+ *
+ * @param string $type Product type.
+ * @param string $mpn MPN to import.
+ */
+ public function test_mpn_csv_import( string $type, string $mpn ): void {
+ $class = WC_Product_Factory::get_product_classname( 0, $type );
+ $product = new $class();
+ $product->set_name( 'MPN test product' );
+ $product->set_status( ProductStatus::PUBLISH );
+ $product->set_regular_price( '10' );
+ if ( ProductType::VARIATION === $type ) {
+ $parent = new WC_Product_Variable();
+ $parent->save();
+ $product->set_parent_id( $parent->get_id() );
+ }
+ $product->set_mpn( 'ORIGINAL' );
+ $product->save();
+ $csv = "ID,MPN\n{$product->get_id()},{$mpn}\n";
+
+ $temporary_file = wp_tempnam( 'mpn' );
+ $file = $temporary_file . '.csv';
+ rename( $temporary_file, $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- The importer requires a CSV extension on the temporary fixture.
+ try {
+ file_put_contents( $file, $csv ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Write an isolated temporary CSV fixture.
+ $sut = new WC_Product_CSV_Importer(
+ $file,
+ array(
+ 'mapping' => array(
+ 'ID' => 'id',
+ 'MPN' => 'mpn',
+ ),
+ 'update_existing' => true,
+ 'parse' => true,
+ )
+ );
+ $result = $sut->import();
+
+ $this->assertSame( array( $product->get_id() ), $result['updated'], wp_json_encode( $result ) );
+ $this->assertSame( $mpn, wc_get_product( $product->get_id() )->get_mpn() );
+ } finally {
+ wp_delete_file( $file );
+ }
+ }
+
/**
* @testdox variations need to set the status back to published if parent product is a draft
*/
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
index 1a6b6f089a4..06f2dda6731 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
@@ -81,6 +81,7 @@ class WC_REST_Products_V2_Controller_Test extends WC_REST_Unit_Test_Case {
'description',
'short_description',
'sku',
+ 'mpn',
'price',
'regular_price',
'sale_price',
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
index 8eacee85f2d..5b918ab8ce2 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
@@ -175,6 +175,7 @@ class WC_REST_Products_Controller_Tests extends WC_Unit_Test_Case {
'description',
'short_description',
'sku',
+ 'mpn',
'global_unique_id',
'price',
'regular_price',