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;
+ }
+}