Commit 3f321d09643 for woocommerce

commit 3f321d09643ae3462599ee590c578d59c93c6080
Author: Ann <annchichi@users.noreply.github.com>
Date:   Wed Jul 15 13:27:57 2026 +0800

    Add warnings for shadowed shipping zones (#66348)

    * Add shipping zone order conflict warnings

    * Add changelog entry for shipping zone warning

    * Fix shipping zone warning lint

    * Handle custom shipping zone locations

diff --git a/plugins/woocommerce/changelog/30340-shipping-zone-order-warning b/plugins/woocommerce/changelog/30340-shipping-zone-order-warning
new file mode 100644
index 00000000000..15d1697baab
--- /dev/null
+++ b/plugins/woocommerce/changelog/30340-shipping-zone-order-warning
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Warn merchants when a higher-priority shipping zone prevents a narrower zone from matching.
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-shipping.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-shipping.php
index 7d51580dabb..5c8391065a2 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-shipping.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-shipping.php
@@ -383,7 +383,7 @@ class WC_Settings_Shipping extends WC_Settings_Page {
 			'wc-shipping-zones',
 			'shippingZonesLocalizeScript',
 			array(
-				'zones'                   => WC_Shipping_Zones::get_zones( 'json' ),
+				'zones'                   => WC_Shipping_Zones::get_zones_with_order_conflict_warnings( 'json' ),
 				'default_zone'            => array(
 					'zone_id'    => 0,
 					'zone_name'  => '',
diff --git a/plugins/woocommerce/includes/admin/settings/views/html-admin-page-shipping-zones.php b/plugins/woocommerce/includes/admin/settings/views/html-admin-page-shipping-zones.php
index cca77f8ab6c..6668a9dccd0 100644
--- a/plugins/woocommerce/includes/admin/settings/views/html-admin-page-shipping-zones.php
+++ b/plugins/woocommerce/includes/admin/settings/views/html-admin-page-shipping-zones.php
@@ -105,6 +105,11 @@ if ( ! defined( 'ABSPATH' ) ) {
 		</td>
 		<td class="wc-shipping-zone-region">
 			{{ data.formatted_zone_location }}
+			<# if ( data.zone_order_conflict_warning ) { #>
+				<div class="notice notice-warning inline wc-shipping-zone-order-conflict-warning">
+					<p>{{ data.zone_order_conflict_warning }}</p>
+				</div>
+			<# } #>
 		</td>
 		<td class="wc-shipping-zone-methods">
 			<div><ul></ul></div>
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index b1bf152c1bb..3f41fd34875 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -3433,7 +3433,7 @@ class WC_AJAX {
 		do_action( 'woocommerce_update_options' );
 		wp_send_json_success(
 			array(
-				'zones' => WC_Shipping_Zones::get_zones( 'json' ),
+				'zones' => WC_Shipping_Zones::get_zones_with_order_conflict_warnings( 'json' ),
 			)
 		);
 	}
diff --git a/plugins/woocommerce/includes/class-wc-shipping-zones.php b/plugins/woocommerce/includes/class-wc-shipping-zones.php
index 79efb39e65d..c1971fd6602 100644
--- a/plugins/woocommerce/includes/class-wc-shipping-zones.php
+++ b/plugins/woocommerce/includes/class-wc-shipping-zones.php
@@ -37,6 +37,242 @@ class WC_Shipping_Zones {
 		return $zones;
 	}

+	/**
+	 * Get shipping zones from the database with admin warnings for zones that may not match because of their order.
+	 *
+	 * @since 11.1.0
+	 * @param string $context Getting shipping methods for what context. Valid values, admin, json.
+	 * @return array Array of arrays.
+	 */
+	public static function get_zones_with_order_conflict_warnings( $context = 'admin' ) {
+		$zones = self::get_zones( $context );
+
+		return self::add_zone_order_conflict_warnings( $zones );
+	}
+
+	/**
+	 * Add warnings to zones that are fully covered by a higher-priority zone.
+	 *
+	 * @since 11.1.0
+	 * @param array $zones Shipping zones.
+	 * @return array Shipping zones with order conflict warnings.
+	 */
+	private static function add_zone_order_conflict_warnings( $zones ) {
+		$ordered_zones         = $zones;
+		$higher_priority_zones = array();
+
+		uasort(
+			$ordered_zones,
+			function ( $zone_a, $zone_b ) {
+				$order_a = absint( $zone_a['zone_order'] ?? 0 );
+				$order_b = absint( $zone_b['zone_order'] ?? 0 );
+
+				if ( $order_a === $order_b ) {
+					return absint( $zone_a['zone_id'] ?? 0 ) <=> absint( $zone_b['zone_id'] ?? 0 );
+				}
+
+				return $order_a <=> $order_b;
+			}
+		);
+
+		foreach ( $ordered_zones as $zone ) {
+			$shadowing_zone = self::get_shadowing_zone( $zone, $higher_priority_zones );
+
+			if ( $shadowing_zone ) {
+				$zones[ $zone['zone_id'] ]['zone_order_conflict_warning'] = sprintf(
+					/* translators: %1$s: Higher-priority shipping zone name. */
+					__( 'This zone will not be matched because "%1$s" covers the same region earlier in the list. Move this zone above "%1$s" to make it available.', 'woocommerce' ),
+					$shadowing_zone['zone_name']
+				);
+			}
+
+			$higher_priority_zones[] = $zone;
+		}
+
+		return $zones;
+	}
+
+	/**
+	 * Get the first higher-priority zone that fully covers a zone.
+	 *
+	 * @since 11.1.0
+	 * @param array $zone Zone to check.
+	 * @param array $higher_priority_zones Higher-priority zones.
+	 * @return array|null Shadowing zone, or null when none exists.
+	 */
+	private static function get_shadowing_zone( $zone, $higher_priority_zones ) {
+		foreach ( $higher_priority_zones as $higher_priority_zone ) {
+			if ( self::zone_covers_zone( $higher_priority_zone, $zone ) ) {
+				return $higher_priority_zone;
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * Check whether a possible broader zone fully covers another zone.
+	 *
+	 * @since 11.1.0
+	 * @param array $possible_broader_zone Possible broader zone.
+	 * @param array $zone Zone to check.
+	 * @return bool Whether the possible broader zone fully covers the zone.
+	 */
+	private static function zone_covers_zone( $possible_broader_zone, $zone ) {
+		if ( self::zone_has_postcode_locations( $possible_broader_zone ) ) {
+			return false;
+		}
+
+		if (
+			self::zone_has_unsupported_non_postcode_locations( $possible_broader_zone ) ||
+			self::zone_has_unsupported_non_postcode_locations( $zone )
+		) {
+			return false;
+		}
+
+		$broader_locations = self::get_zone_locations_by_type( $possible_broader_zone, array( 'continent', 'country', 'state' ) );
+		$zone_locations    = self::get_zone_locations_by_type( $zone, array( 'continent', 'country', 'state' ) );
+
+		if ( empty( $zone_locations ) ) {
+			return empty( $broader_locations );
+		}
+
+		if ( empty( $broader_locations ) ) {
+			return true;
+		}
+
+		foreach ( $zone_locations as $zone_location ) {
+			$is_location_covered = false;
+
+			foreach ( $broader_locations as $broader_location ) {
+				if ( self::location_covers_location( $broader_location, $zone_location ) ) {
+					$is_location_covered = true;
+					break;
+				}
+			}
+
+			if ( ! $is_location_covered ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Check whether a zone has postcode locations.
+	 *
+	 * @since 11.1.0
+	 * @param array $zone Shipping zone.
+	 * @return bool Whether the zone has postcode locations.
+	 */
+	private static function zone_has_postcode_locations( $zone ) {
+		return ! empty( self::get_zone_locations_by_type( $zone, array( 'postcode' ) ) );
+	}
+
+	/**
+	 * Check whether a zone has non-postcode locations not understood by this warning helper.
+	 *
+	 * @since 11.1.0
+	 * @param array $zone Shipping zone.
+	 * @return bool Whether the zone has unsupported non-postcode locations.
+	 */
+	private static function zone_has_unsupported_non_postcode_locations( $zone ) {
+		$locations                = $zone['zone_locations'] ?? array();
+		$supported_location_types = array( 'continent', 'country', 'state', 'postcode' );
+
+		foreach ( $locations as $location ) {
+			if ( isset( $location->type ) && ! in_array( $location->type, $supported_location_types, true ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Get zone locations for the requested location types.
+	 *
+	 * @since 11.1.0
+	 * @param array $zone Shipping zone.
+	 * @param array $location_types Location types.
+	 * @return array Zone locations.
+	 */
+	private static function get_zone_locations_by_type( $zone, $location_types ) {
+		$locations = $zone['zone_locations'] ?? array();
+
+		return array_values(
+			array_filter(
+				$locations,
+				function ( $location ) use ( $location_types ) {
+					return isset( $location->type ) && in_array( $location->type, $location_types, true );
+				}
+			)
+		);
+	}
+
+	/**
+	 * Check whether one location covers another location.
+	 *
+	 * @since 11.1.0
+	 * @param object $possible_broader_location Possible broader location.
+	 * @param object $location Location to check.
+	 * @return bool Whether the possible broader location covers the location.
+	 */
+	private static function location_covers_location( $possible_broader_location, $location ) {
+		$broader_type = $possible_broader_location->type ?? '';
+		$broader_code = $possible_broader_location->code ?? '';
+		$type         = $location->type ?? '';
+		$code         = $location->code ?? '';
+
+		if ( $broader_type === $type && $broader_code === $code ) {
+			return true;
+		}
+
+		if ( 'continent' === $broader_type ) {
+			return self::get_continent_code_for_location( $location ) === $broader_code;
+		}
+
+		if ( 'country' === $broader_type ) {
+			return 'state' === $type && self::get_country_code_from_state_code( $code ) === $broader_code;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Get the continent code for a country or state location.
+	 *
+	 * @since 11.1.0
+	 * @param object $location Location.
+	 * @return string Continent code, or an empty string.
+	 */
+	private static function get_continent_code_for_location( $location ) {
+		$type = $location->type ?? '';
+		$code = $location->code ?? '';
+
+		if ( 'continent' === $type ) {
+			return $code;
+		}
+
+		$country_code = 'state' === $type ? self::get_country_code_from_state_code( $code ) : $code;
+
+		return WC()->countries->get_continent_code_for_country( $country_code );
+	}
+
+	/**
+	 * Get the country code from a state location code.
+	 *
+	 * @since 11.1.0
+	 * @param string $state_code State location code.
+	 * @return string Country code.
+	 */
+	private static function get_country_code_from_state_code( $state_code ) {
+		$state_code_parts = explode( ':', $state_code );
+
+		return $state_code_parts[0] ?? '';
+	}
+
 	/**
 	 * Retrieve Shipping_Zone data objects for the given zone_ids.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-shipping-zones-test.php b/plugins/woocommerce/tests/php/includes/class-wc-shipping-zones-test.php
new file mode 100644
index 00000000000..e1f7d074827
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-shipping-zones-test.php
@@ -0,0 +1,147 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Class WC_Shipping_Zones_Test file.
+ *
+ * @package WooCommerce\Tests\Shipping
+ */
+
+/**
+ * Tests for the WC_Shipping_Zones class.
+ */
+class WC_Shipping_Zones_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		WC_Helper_Shipping_Zones::remove_mock_zones();
+	}
+
+	/**
+	 * Clean up after each test.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+
+		remove_filter( 'woocommerce_valid_location_types', array( $this, 'add_custom_location_type' ) );
+		WC_Helper_Shipping_Zones::remove_mock_zones();
+	}
+
+	/**
+	 * @testdox Should warn when a postcode zone is below a broader state zone.
+	 */
+	public function test_warns_when_postcode_zone_is_below_broader_state_zone(): void {
+		$state_zone = $this->create_zone(
+			'Washington State',
+			0,
+			array(
+				array( 'US:WA', 'state' ),
+			)
+		);
+		$zip_zone   = $this->create_zone(
+			'Washington ZIP',
+			1,
+			array(
+				array( 'US:WA', 'state' ),
+				array( '98012', 'postcode' ),
+			)
+		);
+
+		$zones = WC_Shipping_Zones::get_zones_with_order_conflict_warnings( 'json' );
+
+		$this->assertArrayHasKey( $zip_zone->get_id(), $zones );
+		$this->assertArrayHasKey( 'zone_order_conflict_warning', $zones[ $zip_zone->get_id() ] );
+		$this->assertStringContainsString( $state_zone->get_zone_name(), $zones[ $zip_zone->get_id() ]['zone_order_conflict_warning'] );
+	}
+
+	/**
+	 * @testdox Should not warn when a postcode zone is above a broader state zone.
+	 */
+	public function test_does_not_warn_when_postcode_zone_is_above_broader_state_zone(): void {
+		$zip_zone = $this->create_zone(
+			'Washington ZIP',
+			0,
+			array(
+				array( 'US:WA', 'state' ),
+				array( '98012', 'postcode' ),
+			)
+		);
+		$this->create_zone(
+			'Washington State',
+			1,
+			array(
+				array( 'US:WA', 'state' ),
+			)
+		);
+
+		$zones = WC_Shipping_Zones::get_zones_with_order_conflict_warnings( 'json' );
+
+		$this->assertArrayHasKey( $zip_zone->get_id(), $zones );
+		$this->assertArrayNotHasKey( 'zone_order_conflict_warning', $zones[ $zip_zone->get_id() ] );
+	}
+
+	/**
+	 * @testdox Should not warn when a higher-priority zone has only custom locations.
+	 */
+	public function test_does_not_warn_when_higher_priority_zone_has_only_custom_locations(): void {
+		add_filter( 'woocommerce_valid_location_types', array( $this, 'add_custom_location_type' ) );
+
+		$this->create_zone(
+			'Custom Region',
+			0,
+			array(
+				array( 'custom-region', 'custom_location' ),
+			)
+		);
+		$state_zone = $this->create_zone(
+			'Washington State',
+			1,
+			array(
+				array( 'US:WA', 'state' ),
+			)
+		);
+
+		$zones = WC_Shipping_Zones::get_zones_with_order_conflict_warnings( 'json' );
+
+		$this->assertArrayHasKey( $state_zone->get_id(), $zones );
+		$this->assertArrayNotHasKey( 'zone_order_conflict_warning', $zones[ $state_zone->get_id() ] );
+	}
+
+	/**
+	 * Add a custom location type for tests.
+	 *
+	 * @param array $location_types Location types.
+	 * @return array Location types.
+	 */
+	public function add_custom_location_type( array $location_types ): array {
+		$location_types[] = 'custom_location';
+
+		return $location_types;
+	}
+
+	/**
+	 * Create a shipping zone for tests.
+	 *
+	 * @param string $name Zone name.
+	 * @param int    $order Zone order.
+	 * @param array  $locations Zone locations.
+	 * @return WC_Shipping_Zone
+	 */
+	private function create_zone( string $name, int $order, array $locations ): WC_Shipping_Zone {
+		$zone = new WC_Shipping_Zone();
+		$zone->set_zone_name( $name );
+		$zone->set_zone_order( $order );
+
+		foreach ( $locations as $location ) {
+			$zone->add_location( $location[0], $location[1] );
+		}
+
+		$zone->save();
+
+		return $zone;
+	}
+}