Commit cd7dde9becb for woocommerce

commit cd7dde9becb87c47df94da6d586c748f10bbe468
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Wed Aug 19 12:54:11 2026 +0300

    Add role column to Analytics customers report and CSV export (#67653)

    * Add role column to Analytics customers report and CSV export

    * Fix country cell index in customers table test after role column insertion

    * Remove stale prepare_item_for_export baseline entry resolved by docblock fix

    * Cover role column in customers report CSV export test

    * Make role column visible by default in customers report

diff --git a/plugins/woocommerce/changelog/add-role-to-customers-report b/plugins/woocommerce/changelog/add-role-to-customers-report
new file mode 100644
index 00000000000..a6413b6f67b
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-role-to-customers-report
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a Role column to the Analytics > Customers report and its CSV export, showing each customer's WordPress role(s).
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js b/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
index 0cf14694016..cfe49039982 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
@@ -49,6 +49,10 @@ function CustomersReportTable( {
 				key: 'username',
 				hiddenByDefault: true,
 			},
+			{
+				label: __( 'Role', 'woocommerce' ),
+				key: 'role',
+			},
 			{
 				label: __( 'Last active', 'woocommerce' ),
 				key: 'date_last_active',
@@ -144,6 +148,7 @@ function CustomersReportTable( {
 				user_id: userId,
 				orders_count: ordersCount,
 				username,
+				role,
 				total_spend: totalSpend,
 				postcode,
 				city,
@@ -201,6 +206,10 @@ function CustomersReportTable( {
 					display: username,
 					value: username,
 				},
+				{
+					display: role,
+					value: role,
+				},
 				{
 					display: dateLastActiveDisplay,
 					value: dateLastActive,
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js b/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js
index 2178fb6b124..1a0a96d6ff2 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js
@@ -71,8 +71,8 @@ const baseCustomer = {
 	country: '',
 };

-// Country cell is the 9th column (0-indexed: 8) per getHeadersContent in table.js.
-const COUNTRY_COL = 8;
+// Country cell is the 10th column (0-indexed: 9) per getHeadersContent in table.js.
+const COUNTRY_COL = 9;

 function getCountryCell( customer ) {
 	captured.getRowsContent = null;
@@ -162,8 +162,8 @@ describe( 'CustomersReportTable country cell', () => {

 describe( 'CustomersReportTable phone cells', () => {
 	// Phone cells are the last two columns per getHeadersContent in table.js.
-	const BILLING_PHONE_COL = 12;
-	const SHIPPING_PHONE_COL = 13;
+	const BILLING_PHONE_COL = 13;
+	const SHIPPING_PHONE_COL = 14;

 	beforeEach( () => {
 		jest.clearAllMocks();
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index d07aefbf59c..ae53468e843 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -41085,12 +41085,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/Reports/Customers/Controller.php

-		-
-			message: '#^@param object \$export_item does not accept actual type of parameter\: array\{name\: mixed, username\: mixed, last_active\: mixed, registered\: mixed, email\: mixed, orders_count\: mixed, total_spend\: string, avg_order_value\: string, \.\.\.\}\.$#'
-			identifier: parameter.phpDocType
-			count: 1
-			path: src/Admin/API/Reports/Customers/Controller.php
-
 		-
 			message: '#^@param object \$item does not accept actual type of parameter\: array\.$#'
 			identifier: parameter.phpDocType
@@ -41121,12 +41115,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/Reports/Customers/Controller.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\Reports\\Customers\\Controller\:\:prepare_item_for_export\(\) should return array but returns object\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Admin/API/Reports/Customers/Controller.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\Reports\\Customers\\Controller\:\:prepare_item_for_response\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
 			identifier: missingType.generics
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
index a608bf67acc..236f607fb66 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
@@ -42,8 +42,19 @@ class Controller extends GenericController implements ExportableInterface {
 	 * @return mixed Results from the data store.
 	 */
 	protected function get_datastore_data( $query_args = array() ) {
-		$query = new Query( $query_args );
-		return $query->get_data();
+		$query       = new Query( $query_args );
+		$report_data = $query->get_data();
+
+		// Warm the user caches in one query so the per-item role lookup in
+		// prepare_item_for_response() doesn't query per row.
+		if ( ! empty( $report_data->data ) ) {
+			$user_ids = array_filter( array_map( 'absint', wp_list_pluck( $report_data->data, 'user_id' ) ) );
+			if ( ! empty( $user_ids ) ) {
+				cache_users( $user_ids );
+			}
+		}
+
+		return $report_data;
 	}

 	/**
@@ -240,6 +251,7 @@ class Controller extends GenericController implements ExportableInterface {
 		// Last active date is local time.
 		$data['date_last_active_gmt'] = wc_rest_prepare_date_response( $data['date_last_active'], false );
 		$data['date_last_active']     = wc_rest_prepare_date_response( $data['date_last_active'] );
+		$data['role']                 = $this->get_user_role_names( $data['user_id'] ?? 0 );
 		// Rows can be served from a report cache written before these columns existed.
 		$data['billing_phone']  = $data['billing_phone'] ?? '';
 		$data['shipping_phone'] = $data['shipping_phone'] ?? '';
@@ -261,6 +273,35 @@ class Controller extends GenericController implements ExportableInterface {
 		return apply_filters( 'woocommerce_rest_prepare_report_customers', $response, $report, $request );
 	}

+	/**
+	 * Get the localized role names of a user as a comma-separated string.
+	 *
+	 * Roles are resolved at response time rather than stored in the customer
+	 * lookup table, since role changes (e.g. via WP_User::set_role() or plugins
+	 * editing capabilities directly) would leave a stored copy stale.
+	 *
+	 * @param int $user_id User ID, 0 for guest customers.
+	 * @return string Comma-separated localized role names, empty for guests and deleted users.
+	 */
+	protected function get_user_role_names( $user_id ) {
+		if ( empty( $user_id ) ) {
+			return '';
+		}
+
+		$user = get_userdata( $user_id );
+		if ( ! $user ) {
+			return '';
+		}
+
+		$role_names = array();
+		foreach ( $user->roles as $role ) {
+			$name         = wp_roles()->role_names[ $role ] ?? $role;
+			$role_names[] = translate_user_role( $name );
+		}
+
+		return implode( ', ', $role_names );
+	}
+
 	/**
 	 * Prepare links for the request.
 	 *
@@ -335,6 +376,12 @@ class Controller extends GenericController implements ExportableInterface {
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
+				'role'                 => array(
+					'description' => __( 'Role(s) of the user, comma-separated. Empty for guest customers.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+					'readonly'    => true,
+				),
 				'country'              => array(
 					'description' => __( 'Country / Region.', 'woocommerce' ),
 					'type'        => 'string',
@@ -707,6 +754,7 @@ class Controller extends GenericController implements ExportableInterface {
 		$export_columns = array(
 			'name'            => __( 'Name', 'woocommerce' ),
 			'username'        => __( 'Username', 'woocommerce' ),
+			'role'            => __( 'Role', 'woocommerce' ),
 			'last_active'     => __( 'Last Active', 'woocommerce' ),
 			'registered'      => __( 'Sign Up', 'woocommerce' ),
 			'email'           => __( 'Email', 'woocommerce' ),
@@ -743,6 +791,7 @@ class Controller extends GenericController implements ExportableInterface {
 		$export_item = array(
 			'name'            => $item['name'],
 			'username'        => $item['username'],
+			'role'            => $item['role'] ?? '',
 			'last_active'     => $item['date_last_active'],
 			'registered'      => $item['date_registered'],
 			'email'           => $item['email'],
@@ -760,7 +809,7 @@ class Controller extends GenericController implements ExportableInterface {
 		/**
 		 * Filter the column values of an item being exported.
 		 *
-		 * @param object $export_item Key value pair of Column ID => Row Value.
+		 * @param array  $export_item Key value pair of Column ID => Row Value.
 		 * @param object $item        Single report item/row.
 		 * @since 4.0.0
 		 */
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
index 4709afcae5b..7dbe140c5ac 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
@@ -79,6 +79,7 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'last_name', $schema );
 		$this->assertArrayHasKey( 'email', $schema );
 		$this->assertArrayHasKey( 'username', $schema );
+		$this->assertArrayHasKey( 'role', $schema );
 		$this->assertArrayHasKey( 'country', $schema );
 		$this->assertArrayHasKey( 'city', $schema );
 		$this->assertArrayHasKey( 'state', $schema );
@@ -107,7 +108,7 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
 		$data       = $response->get_data();
 		$properties = $data['schema']['properties'];

-		$this->assertCount( 20, $properties );
+		$this->assertCount( 21, $properties );
 		$this->assert_report_item_schema( $properties );
 	}

@@ -203,6 +204,70 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( $admin_id, $reports[1]['user_id'] );
 	}

+	/**
+	 * @testdox Should include localized user roles in the response and an empty role for guests.
+	 */
+	public function test_customer_role_in_response() {
+		wp_set_current_user( $this->user );
+
+		$customer = WC_Helper_Customer::create_customer( 'rolecustomer', 'password', 'role-customer@example.com' );
+
+		$editor_id = wp_insert_user(
+			array(
+				'user_login' => 'roleeditor',
+				'user_pass'  => 'password',
+				'user_email' => 'role-editor@example.com',
+				'role'       => 'editor',
+			)
+		);
+		$editor    = new WP_User( $editor_id );
+		$editor->add_role( 'shop_manager' );
+
+		// Editors are not synced as registered customers, so they enter the report via an order.
+		$editor_order = WC_Helper_Order::create_order( $editor_id );
+
+		// Order with guest customer (no account).
+		$guest_order = WC_Helper_Order::create_order( 0 );
+		$guest_order->set_billing_email( 'role-guest@example.com' );
+		$guest_order->save();
+
+		// Sync the lookup table directly to keep the test independent of the queue.
+		$this->assertNotFalse( CustomersDataStore::update_registered_customer( $customer->get_id() ) );
+		$this->assertGreaterThan( 0, CustomersDataStore::get_or_create_customer_from_order( $editor_order ) );
+		$this->assertGreaterThan( 0, CustomersDataStore::get_or_create_customer_from_order( $guest_order ) );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params( array( 'per_page' => 10 ) );
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 3, $reports );
+
+		$roles_by_user_id = array();
+		foreach ( $reports as $report ) {
+			$roles_by_user_id[ (int) $report['user_id'] ] = $report['role'];
+		}
+
+		$this->assertEquals( 'Customer', $roles_by_user_id[ $customer->get_id() ], 'Registered customers should report their role' );
+		$this->assertEquals( 'Editor, Shop manager', $roles_by_user_id[ $editor_id ], 'Users with multiple roles should report all of them' );
+		$this->assertSame( '', $roles_by_user_id[0], 'Guest customers should report an empty role' );
+
+		$controller     = new \Automattic\WooCommerce\Admin\API\Reports\Customers\Controller();
+		$export_columns = $controller->get_export_columns();
+		$this->assertArrayHasKey( 'role', $export_columns, 'CSV export should include a role column' );
+		$this->assertEquals( 'Role', $export_columns['role'] );
+
+		$export_roles_by_user_id = array();
+		foreach ( $reports as $report ) {
+			$export_item = $controller->prepare_item_for_export( $report );
+			$export_roles_by_user_id[ (int) $report['user_id'] ] = $export_item['role'];
+		}
+
+		$this->assertEquals( 'Editor, Shop manager', $export_roles_by_user_id[ $editor_id ], 'CSV export should carry the role value' );
+		$this->assertSame( '', $export_roles_by_user_id[0], 'CSV export should leave the role empty for guests' );
+	}
+
 	/**
 	 * Test getting reports.
 	 *