diff --git a/db.sql b/db.sql
index 876be41..009c706 100644
--- a/db.sql
+++ b/db.sql
@@ -793,6 +793,9 @@ CREATE TABLE `t_kpi_area_info` (
`City` VARCHAR(128) NULL COMMENT '城市',
`LeftPostcode` VARCHAR(32) NULL COMMENT '左侧邮编',
`RightPostcode` VARCHAR(32) NULL COMMENT '右侧邮编',
+ `MatchDim` VARCHAR(16) NOT NULL DEFAULT 'country' COMMENT '匹配维度:zip/state/city/country',
+ `MatchMode` VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT '匹配方式:prefix/exact/range/all',
+ `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT '邮编前缀长度(zip+prefix)',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -800,7 +803,8 @@ CREATE TABLE `t_kpi_area_info` (
`UpdateTime` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`Id`),
KEY `idx_kpi_info_area` (`AreaId`),
- KEY `idx_kpi_info_area_country` (`AreaId`, `CountryCode`)
+ KEY `idx_kpi_info_area_country` (`AreaId`, `CountryCode`),
+ KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`)
) COMMENT='分区明细表';
CREATE TABLE `t_weight_rule` (
diff --git a/sql/admin.sql b/sql/admin.sql
new file mode 100644
index 0000000..07f3ba7
--- /dev/null
+++ b/sql/admin.sql
@@ -0,0 +1,361 @@
+-- =============================================================================
+-- 存量库增量迁移(合并自 sql/migrate_*.sql)
+-- 可按整文件执行;多数段可重复执行(information_schema 判断)。
+-- 注意:揽收 Direction / FeePush 两段为裸 ALTER,已执行过的库请跳过或手工注释。
+-- =============================================================================
+
+SET @db := DATABASE();
+
+-- -----------------------------------------------------------------------------
+-- 1) 排货规则服务商表:MatchCondition
+-- 原:migrate_allocation_match_condition.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_sales_product_allocation_provider'
+ AND COLUMN_NAME = 'MatchCondition'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_sales_product_allocation_provider` ADD COLUMN `MatchCondition` TEXT NULL COMMENT ''排货判断条件(中文公式),空表示全部订单可用'' AFTER `CustomerIds`',
+ 'SELECT ''MatchCondition already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 2) 客户出口资质备案表(含 IsDefault / Remark)
+-- 原:migrate_customer_export_qualification.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'CREATE TABLE `t_customer_export_qualification` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+ `CustomerId` INT NOT NULL COMMENT ''归属客户 t_customer.Id'',
+ `CompanyName` VARCHAR(200) NOT NULL COMMENT ''企业名称'',
+ `SocialCreditCode` VARCHAR(64) NOT NULL COMMENT ''企业社会信用代码'',
+ `DomesticConsignor` VARCHAR(200) NOT NULL COMMENT ''境内发货人'',
+ `DomesticConsignorCustomsCode` VARCHAR(64) NULL COMMENT ''境内发货人海关代码'',
+ `ProducerName` VARCHAR(200) NOT NULL COMMENT ''生产销售单位'',
+ `ProducerCustomsCode` VARCHAR(64) NULL COMMENT ''生产销售单位海关代码'',
+ `CustomsPort` VARCHAR(100) NULL COMMENT ''备案海关港口地'',
+ `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'',
+ `Remark` VARCHAR(512) NULL COMMENT ''备注'',
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
+ `UpdateUserId` INT NULL COMMENT ''更新人'',
+ `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
+ PRIMARY KEY (`Id`),
+ KEY `idx_ceq_customer` (`CustomerId`, `IsDeleted`, `Id`),
+ KEY `idx_ceq_credit` (`SocialCreditCode`, `IsDeleted`),
+ KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)
+) COMMENT=''客户出口资质备案''',
+ 'SELECT ''t_customer_export_qualification already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+ AND COLUMN_NAME = 'IsDefault'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_customer_export_qualification`
+ ADD COLUMN `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'' AFTER `CustomsPort`',
+ 'SELECT ''IsDefault already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+ AND COLUMN_NAME = 'Remark'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_customer_export_qualification`
+ ADD COLUMN `Remark` VARCHAR(512) NULL COMMENT ''备注'' AFTER `IsDefault`',
+ 'SELECT ''Remark already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @idx := (
+ SELECT COUNT(1)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_export_qualification'
+ AND INDEX_NAME = 'idx_ceq_customer_default'
+);
+SET @sql := IF(
+ @idx = 0,
+ 'ALTER TABLE `t_customer_export_qualification` ADD KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)',
+ 'SELECT ''idx_ceq_customer_default already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 3) t_order:出口资质备案 Id
+-- 原:migrate_order_export_qualification_id.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order'
+ AND COLUMN_NAME = 'ExportQualificationId'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order`
+ ADD COLUMN `ExportQualificationId` INT NULL COMMENT ''出口资质备案 Id(t_customer_export_qualification.Id)'' AFTER `ExportCustomsMode`,
+ ADD KEY `idx_order_export_qualification` (`ExportQualificationId`)',
+ 'SELECT ''t_order.ExportQualificationId already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 4) SKU / 订单申报:DeclareElement
+-- 原:migrate_declare_element.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 'c_sku'
+ AND COLUMN_NAME = 'DeclareElement'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `c_sku` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ImHsCode`',
+ 'SELECT ''c_sku.DeclareElement already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order_declaration'
+ AND COLUMN_NAME = 'DeclareElement'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order_declaration` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ExHsCode`',
+ 'SELECT ''t_order_declaration.DeclareElement already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 5) 问题模板:处理人字段
+-- 原:migrate_issue_template_handler.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_issue_template'
+ AND COLUMN_NAME = 'HandlerType'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerType` VARCHAR(16) NULL COMMENT ''处理人类型:customer=订单所属客户,user=指定用户'' AFTER `NotifyMethod`',
+ 'SELECT ''HandlerType already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_issue_template'
+ AND COLUMN_NAME = 'HandlerUserId'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerUserId` INT NULL COMMENT ''指定处理人用户Id(HandlerType=user 时必填)'' AFTER `HandlerType`',
+ 'SELECT ''HandlerUserId already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_issue_template`
+SET `HandlerType` = 'user'
+WHERE (`HandlerType` IS NULL OR `HandlerType` = '')
+ AND `IsDeleted` = 0;
+
+-- -----------------------------------------------------------------------------
+-- 6) 揽收方向 + 客户地址类型
+-- 原:migrate_pickup_direction_and_address_type.sql
+-- 注意:裸 ALTER,已执行过勿再跑本段
+-- -----------------------------------------------------------------------------
+ALTER TABLE `t_pickup_order`
+ ADD COLUMN `Direction` VARCHAR(16) NOT NULL DEFAULT 'forward'
+ COMMENT 'forward正向(客户→仓)/reverse逆向(仓→客户)'
+ AFTER `SenderAddressId`;
+
+ALTER TABLE `t_pickup_order`
+ ADD KEY `idx_pickup_direction_status` (`Direction`, `Status`, `CreateTime`);
+
+ALTER TABLE `t_customer_sender_address`
+ MODIFY COLUMN `AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender'
+ COMMENT 'sender国内发件/return国内退件/pickup国内揽收/receiver海外收件';
+
+-- -----------------------------------------------------------------------------
+-- 7) 揽收单:承运推送件重尺 + 费用快照
+-- 原:migrate_pickup_fee_push.sql
+-- 注意:裸 ALTER,已执行过勿再跑本段
+-- -----------------------------------------------------------------------------
+ALTER TABLE `t_pickup_order`
+ ADD COLUMN `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重' AFTER `ChargeableWeight`,
+ ADD COLUMN `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON' AFTER `VolumetricWeight`,
+ ADD COLUMN `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)' AFTER `PackageSizesJson`,
+ ADD COLUMN `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)' AFTER `CarrierFreightWeight`,
+ ADD COLUMN `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)' AFTER `CarrierQuotedFreightAmount`,
+ ADD COLUMN `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文' AFTER `CarrierQuotedReceivableAmount`,
+ ADD COLUMN `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON' AFTER `CarrierQuoteJson`,
+ ADD COLUMN `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON' AFTER `ReceivableFeeJson`,
+ ADD COLUMN `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间' AFTER `PayableFeeJson`;
+
+-- -----------------------------------------------------------------------------
+-- 8) 客户 OpenAPI 机机凭证
+-- 原:migrate_customer_openapi_credential.sql
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_customer_openapi_credential'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'CREATE TABLE `t_customer_openapi_credential` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+ `CustomerId` INT NOT NULL COMMENT ''归属主客户 t_customer.Id'',
+ `AppKey` VARCHAR(64) NOT NULL COMMENT ''开放接口 AppKey'',
+ `AppSecret` VARCHAR(128) NOT NULL COMMENT ''开放接口 AppSecret(明文,创建/重置时返回)'',
+ `Enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:0-否,1-是'',
+ `LastUsedAt` DATETIME NULL COMMENT ''最近鉴权成功时间'',
+ `LastUsedIp` VARCHAR(64) NULL COMMENT ''最近鉴权成功 IP'',
+ `Remark` VARCHAR(512) NULL COMMENT ''备注'',
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
+ `UpdateUserId` INT NULL COMMENT ''更新人'',
+ `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
+ PRIMARY KEY (`Id`),
+ UNIQUE KEY `uk_coc_customer` (`CustomerId`),
+ UNIQUE KEY `uk_coc_app_key` (`AppKey`),
+ KEY `idx_coc_enabled` (`Enabled`, `IsDeleted`)
+) COMMENT=''客户 OpenAPI 机机凭证''',
+ 'SELECT ''t_customer_openapi_credential already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 9) 分区明细:匹配维度 / 方式 / 前缀长度
+-- -----------------------------------------------------------------------------
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND COLUMN_NAME = 'MatchDim'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_kpi_area_info`
+ ADD COLUMN `MatchDim` VARCHAR(16) NOT NULL DEFAULT ''country'' COMMENT ''匹配维度:zip/state/city/country'' AFTER `RightPostcode`,
+ ADD COLUMN `MatchMode` VARCHAR(16) NOT NULL DEFAULT ''all'' COMMENT ''匹配方式:prefix/exact/range/all'' AFTER `MatchDim`,
+ ADD COLUMN `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT ''邮编前缀长度(zip+prefix)'' AFTER `MatchMode`',
+ 'SELECT ''kpi area match columns already exist'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @idx := (
+ SELECT COUNT(1)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND INDEX_NAME = 'idx_kpi_info_area_dim'
+);
+SET @sql := IF(
+ @idx = 0,
+ 'ALTER TABLE `t_kpi_area_info` ADD KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`)',
+ 'SELECT ''idx_kpi_info_area_dim already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_kpi_area_info`
+SET
+ `MatchDim` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' OR IFNULL(`RightPostcode`, '') <> '' THEN 'zip'
+ WHEN IFNULL(`City`, '') <> '' THEN 'city'
+ WHEN IFNULL(`Province`, '') <> '' THEN 'state'
+ ELSE 'country'
+ END,
+ `MatchMode` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN 'prefix'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN 'exact'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN 'range'
+ WHEN IFNULL(`City`, '') <> '' OR IFNULL(`Province`, '') <> '' THEN 'exact'
+ ELSE 'all'
+ END,
+ `PrefixLen` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN LEAST(CHAR_LENGTH(`LeftPostcode`), CHAR_LENGTH(`RightPostcode`))
+ ELSE 0
+ END
+WHERE IFNULL(`MatchDim`, '') IN ('', 'country')
+ AND (
+ IFNULL(`LeftPostcode`, '') <> ''
+ OR IFNULL(`RightPostcode`, '') <> ''
+ OR IFNULL(`Province`, '') <> ''
+ OR IFNULL(`City`, '') <> ''
+ );
diff --git a/sql/migrate_kpi_area_info_match.sql b/sql/migrate_kpi_area_info_match.sql
new file mode 100644
index 0000000..1096fae
--- /dev/null
+++ b/sql/migrate_kpi_area_info_match.sql
@@ -0,0 +1,66 @@
+-- 存量库:分区明细增加匹配维度/方式/前缀长度(可重复执行)
+SET @db := DATABASE();
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND COLUMN_NAME = 'MatchDim'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_kpi_area_info`
+ ADD COLUMN `MatchDim` VARCHAR(16) NOT NULL DEFAULT ''country'' COMMENT ''匹配维度:zip/state/city/country'' AFTER `RightPostcode`,
+ ADD COLUMN `MatchMode` VARCHAR(16) NOT NULL DEFAULT ''all'' COMMENT ''匹配方式:prefix/exact/range/all'' AFTER `MatchDim`,
+ ADD COLUMN `PrefixLen` INT NOT NULL DEFAULT 0 COMMENT ''邮编前缀长度(zip+prefix)'' AFTER `MatchMode`',
+ 'SELECT ''kpi area match columns already exist'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @idx := (
+ SELECT COUNT(1)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_info'
+ AND INDEX_NAME = 'idx_kpi_info_area_dim'
+);
+SET @sql := IF(
+ @idx = 0,
+ 'ALTER TABLE `t_kpi_area_info` ADD KEY `idx_kpi_info_area_dim` (`AreaId`, `CountryCode`, `MatchDim`)',
+ 'SELECT ''idx_kpi_info_area_dim already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE `t_kpi_area_info`
+SET
+ `MatchDim` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' OR IFNULL(`RightPostcode`, '') <> '' THEN 'zip'
+ WHEN IFNULL(`City`, '') <> '' THEN 'city'
+ WHEN IFNULL(`Province`, '') <> '' THEN 'state'
+ ELSE 'country'
+ END,
+ `MatchMode` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN 'prefix'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN 'exact'
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN 'range'
+ WHEN IFNULL(`City`, '') <> '' OR IFNULL(`Province`, '') <> '' THEN 'exact'
+ ELSE 'all'
+ END,
+ `PrefixLen` = CASE
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') = '' THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND `LeftPostcode` = `RightPostcode` THEN CHAR_LENGTH(`LeftPostcode`)
+ WHEN IFNULL(`LeftPostcode`, '') <> '' AND IFNULL(`RightPostcode`, '') <> '' THEN LEAST(CHAR_LENGTH(`LeftPostcode`), CHAR_LENGTH(`RightPostcode`))
+ ELSE 0
+ END
+WHERE IFNULL(`MatchDim`, '') IN ('', 'country')
+ AND (
+ IFNULL(`LeftPostcode`, '') <> ''
+ OR IFNULL(`RightPostcode`, '') <> ''
+ OR IFNULL(`Province`, '') <> ''
+ OR IFNULL(`City`, '') <> ''
+ );
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs
index 2536231..efea04f 100644
--- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoOutput.cs
@@ -45,6 +45,21 @@ public class KpiAreaInfoOutput
///
public string? RightPostcode { get; set; }
+ ///
+ /// 匹配维度:zip / state / city / country。
+ ///
+ public string? MatchDim { get; set; }
+
+ ///
+ /// 匹配方式:prefix / exact / range / all。
+ ///
+ public string? MatchMode { get; set; }
+
+ ///
+ /// 邮编前缀长度(zip+prefix)。
+ ///
+ public int PrefixLen { get; set; }
+
///
/// 创建时间。
///
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs
index aa8caf8..e754747 100644
--- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoSaveInput.cs
@@ -44,4 +44,19 @@ public class KpiAreaInfoSaveInput
/// 结束邮编。
///
public string? RightPostcode { get; set; }
+
+ ///
+ /// 匹配维度:zip / state / city / country。
+ ///
+ public string? MatchDim { get; set; }
+
+ ///
+ /// 匹配方式:prefix / exact / range / all。
+ ///
+ public string? MatchMode { get; set; }
+
+ ///
+ /// 邮编前缀长度。
+ ///
+ public int? PrefixLen { get; set; }
}
diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs
index c45bee6..7c6b0a5 100644
--- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs
@@ -55,6 +55,11 @@ public class OrderPriceCheckInput
///
public string? DestinationProvince { get; set; }
+ ///
+ /// 目的城市。
+ ///
+ public string? DestinationCity { get; set; }
+
///
/// 邮编。
///
diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
index feb4d90..d3c698b 100644
--- a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
@@ -23,7 +23,7 @@ public class SalesProductPricePublishSnapshotDto
public string? PriceAreaName { get; set; }
///
- /// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
+ /// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为闭区间。
///
public List PriceAreaInfos { get; set; } = [];
@@ -59,7 +59,7 @@ public class SalesProductRulesPublishSnapshotDto
public string? BlacklistAreaName { get; set; }
///
- /// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
+ /// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。
///
public List BlacklistAreaInfos { get; set; } = [];
@@ -74,7 +74,7 @@ public class SalesProductRulesPublishSnapshotDto
public string? RemoteAreaName { get; set; }
///
- /// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
+ /// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。
///
public List RemoteAreaInfos { get; set; } = [];
diff --git a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
index bd9ba46..eb347f2 100644
--- a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
@@ -17,8 +17,16 @@ public class KpiAreaSnapshotRuleDto
public string? City { get; set; }
+ /// zip / state / city / country,发布时固化。
+ public string? MatchDim { get; set; }
+
+ /// prefix / exact / range / all,发布时固化。
+ public string? MatchMode { get; set; }
+
+ public int PrefixLen { get; set; }
+
///
- /// 邮编区间,左开右闭,如 (50000,90000]。
+ /// 邮编匹配编码:prefix:900 / exact:90001 / [90000,90199];旧快照 (left,right] 按闭区间解析。
///
public string? PostcodeRange { get; set; }
diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs
index 8313e88..7c76aa1 100644
--- a/src/Oms.Application/Services/Channels/KpiAreaService.cs
+++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs
@@ -122,7 +122,7 @@ public class KpiAreaService : ApplicationService
public async Task AddInfoAsync(KpiAreaInfoSaveInput input)
{
await GetAreaOrThrowAsync(input.AreaId);
- await KpiAreaRepository.InsertInfoAsync(new KpiAreaInfo
+ var entity = new KpiAreaInfo
{
AreaId = input.AreaId,
CountryCode = input.CountryCode,
@@ -130,8 +130,13 @@ public class KpiAreaService : ApplicationService
Province = input.Province,
City = input.City,
LeftPostcode = input.LeftPostcode,
- RightPostcode = input.RightPostcode
- });
+ RightPostcode = input.RightPostcode,
+ MatchDim = input.MatchDim,
+ MatchMode = input.MatchMode,
+ PrefixLen = input.PrefixLen ?? 0
+ };
+ PrepareAreaInfo(entity);
+ await KpiAreaRepository.InsertInfoAsync(entity);
}
///
@@ -151,6 +156,10 @@ public class KpiAreaService : ApplicationService
entity.City = input.City;
entity.LeftPostcode = input.LeftPostcode;
entity.RightPostcode = input.RightPostcode;
+ entity.MatchDim = input.MatchDim;
+ entity.MatchMode = input.MatchMode;
+ entity.PrefixLen = input.PrefixLen ?? 0;
+ PrepareAreaInfo(entity);
await KpiAreaRepository.UpdateInfoAsync(entity);
}
@@ -293,8 +302,21 @@ public class KpiAreaService : ApplicationService
};
}
+ private static void PrepareAreaInfo(KpiAreaInfo entity)
+ {
+ try
+ {
+ GeoPartitionMatcher.PrepareEntity(entity);
+ }
+ catch (InvalidOperationException ex)
+ {
+ throw new BusinessException(ex.Message);
+ }
+ }
+
private static KpiAreaInfoOutput MapInfoOutput(KpiAreaInfo entity)
{
+ var (matchDim, matchMode, prefixLen) = GeoPartitionMatcher.Describe(entity);
return new KpiAreaInfoOutput
{
Id = entity.Id,
@@ -305,6 +327,9 @@ public class KpiAreaService : ApplicationService
City = entity.City,
LeftPostcode = entity.LeftPostcode,
RightPostcode = entity.RightPostcode,
+ MatchDim = matchDim,
+ MatchMode = matchMode,
+ PrefixLen = prefixLen,
CreateTime = entity.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
index 28ca2c5..ed2bb5a 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
@@ -277,7 +277,7 @@ public partial class SalesProductService
if (rulesSnapshot.RemoteAreaInfos is { Count: > 0 })
{
- return RulesSnapshotHelper.IsAreaHitFromSnapshot(
+ return GeoPartitionMatcher.IsHit(
rulesSnapshot.RemoteAreaInfos,
countryCode,
province,
@@ -286,12 +286,7 @@ public partial class SalesProductService
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
}
private async Task ResolveSimulateWarehouseNameAsync(int? warehouseId)
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 993df8f..f3c00a6 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -1500,7 +1500,7 @@ public partial class OrderService
if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 })
{
- return RulesSnapshotHelper.IsAreaHitFromSnapshot(
+ return GeoPartitionMatcher.IsHit(
rulesSnapshot.BlacklistAreaInfos,
countryCode,
province,
@@ -1509,12 +1509,7 @@ public partial class OrderService
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
}
private async Task IsRemoteHitAsync(
@@ -1533,7 +1528,7 @@ public partial class OrderService
if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 })
{
- return RulesSnapshotHelper.IsAreaHitFromSnapshot(
+ return GeoPartitionMatcher.IsHit(
rulesSnapshot.RemoteAreaInfos,
countryCode,
province,
@@ -1542,12 +1537,7 @@ public partial class OrderService
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
}
private static int? ResolveEffectiveBlacklistAreaId(
@@ -1649,12 +1639,12 @@ public partial class OrderService
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value);
- var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
+ var matched = GeoPartitionMatcher.MatchEntity(
+ rules,
countryCode,
province,
city,
- zipCode));
+ zipCode);
if (matched == null)
{
return null;
@@ -2998,11 +2988,6 @@ public partial class OrderService
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(channel.RemoteAreaId.Value);
- return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
}
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
index a2b290a..7220889 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
@@ -135,6 +135,7 @@ public partial class OrderService
{
CountryCode = countryCode,
Province = input.DestinationProvince,
+ City = input.DestinationCity,
PostalCode = input.PostalCode,
Length = dimensionLength,
Width = dimensionWidth,
@@ -229,6 +230,7 @@ public partial class OrderService
{
CountryCode = baseContext.CountryCode,
Province = baseContext.Province,
+ City = baseContext.City,
PostalCode = baseContext.PostalCode,
CargoAttribute = cargo.CargoAttribute,
CargoTypeCode = cargo.Code,
@@ -531,8 +533,7 @@ public partial class OrderService
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
}
- if (profile.HasPostalCode
- && await IsBlacklistHitAsync(
+ if (await IsBlacklistHitAsync(
product,
snapshotBinding.Rules,
context.CountryCode,
@@ -588,8 +589,7 @@ public partial class OrderService
ParcelQuoteResult? bestQuote = null;
decimal bestTotal = decimal.MaxValue;
- var isRemote = profile.HasPostalCode
- && await IsRemoteHitAsync(
+ var isRemote = await IsRemoteHitAsync(
product,
snapshotBinding.Rules,
context.CountryCode,
@@ -705,7 +705,7 @@ public partial class OrderService
attempts.Add(new QuoteZoneAttempt(keys, isFallback, keys[0]));
}
- if (profile.HasPostalCode || profile.HasProvince)
+ if (profile.HasPostalCode || profile.HasProvince || profile.HasCity)
{
await TryAddAsync(
profile.HasProvince ? context.Province : null,
diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
index 331c327..0858b86 100644
--- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
+++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
@@ -87,41 +87,8 @@ internal static class ParcelBillingCalculator
string countryCode,
string? province,
string? city,
- string? zipCode)
- {
- if (!string.IsNullOrWhiteSpace(rule.CountryCode)
- && !string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.Province)
- && !string.Equals(rule.Province, province, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.City)
- && !string.Equals(rule.City, city, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.LeftPostcode) || !string.IsNullOrWhiteSpace(rule.RightPostcode))
- {
- if (string.IsNullOrWhiteSpace(zipCode))
- {
- return false;
- }
-
- if (!IsPostcodeInRange(zipCode, rule.LeftPostcode, rule.RightPostcode))
- {
- return false;
- }
- }
-
- return true;
- }
+ string? zipCode) =>
+ GeoPartitionMatcher.IsHit([rule], countryCode, province, city, zipCode);
public static bool IsAreaMatched(
KpiAreaInfoOutput rule,
@@ -146,8 +113,12 @@ internal static class ParcelBillingCalculator
return null;
}
- var matched = priceSnapshot.PriceAreaInfos.FirstOrDefault(rule =>
- KpiAreaSnapshotMapper.IsRuleMatched(rule, countryCode, province, city, zipCode));
+ var matched = GeoPartitionMatcher.MatchSnapshot(
+ priceSnapshot.PriceAreaInfos,
+ countryCode,
+ province,
+ city,
+ zipCode);
if (matched == null)
{
return null;
@@ -2203,24 +2174,6 @@ internal static class ParcelBillingCalculator
}
}
- private static bool IsPostcodeInRange(string zipCode, string? left, string? right)
- {
- if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right))
- {
- return true;
- }
-
- if (long.TryParse(zipCode, out var zip)
- && long.TryParse(left, out var leftValue)
- && long.TryParse(right, out var rightValue))
- {
- return zip >= leftValue && zip <= rightValue;
- }
-
- return string.Compare(zipCode, left, StringComparison.OrdinalIgnoreCase) >= 0
- && string.Compare(zipCode, right, StringComparison.OrdinalIgnoreCase) <= 0;
- }
-
private sealed class ContinuePricingConfig
{
public decimal FirstWeight { get; set; }
diff --git a/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs b/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
index 8d489db..1cc1540 100644
--- a/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
+++ b/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
@@ -56,9 +56,11 @@ internal static class PriceCheckHintBuilder
PriceCheckMissingFieldCodes.Dimensions =>
"未填写包裹尺寸:计费重按实重估算,入库实测尺寸后价格可能变化。",
PriceCheckMissingFieldCodes.PostalCode =>
- "未填写邮编:未校验黑名单/偏远/精确分区,报价仅供参考。",
+ "未填写邮编:优先按州/市或国家级分区匹配,精确邮编可能调整价格。",
PriceCheckMissingFieldCodes.Province =>
- "未填写省州:分区按国家级匹配,精确地址可能调整价格。",
+ "未填写省州:无邮编时无法按州/市降级匹配,分区可能按国家级兜底。",
+ PriceCheckMissingFieldCodes.City =>
+ "未填写城市:无邮编且州未命中时无法按城市匹配。",
PriceCheckMissingFieldCodes.CargoType =>
"未选择货物属性:已分别试算普货/带电/敏感货等,下单时以所选属性为准。",
PriceCheckMissingFieldCodes.Warehouse =>
diff --git a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
index fd72af7..895f8c3 100644
--- a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
+++ b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
@@ -13,6 +13,8 @@ internal sealed class PriceCheckInputProfile
public bool HasProvince { get; init; }
+ public bool HasCity { get; init; }
+
public bool HasCargoTypeFilter { get; init; }
public bool HasWarehouseFilter { get; init; }
@@ -26,6 +28,7 @@ internal sealed class PriceCheckInputProfile
var hasDimensions = input.Length is > 0 && input.Width is > 0 && input.Height is > 0;
var hasPostal = !string.IsNullOrWhiteSpace(input.PostalCode);
var hasProvince = !string.IsNullOrWhiteSpace(input.DestinationProvince);
+ var hasCity = !string.IsNullOrWhiteSpace(input.DestinationCity);
var cargoCodes = (input.CargoTypes ?? [])
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
@@ -69,6 +72,7 @@ internal sealed class PriceCheckInputProfile
HasDimensions = hasDimensions,
HasPostalCode = hasPostal,
HasProvince = hasProvince,
+ HasCity = hasCity,
HasCargoTypeFilter = hasCargoFilter,
HasWarehouseFilter = hasWarehouseFilter,
HasShippingMethodFilter = hasShippingFilter,
@@ -87,6 +91,8 @@ internal static class PriceCheckMissingFieldCodes
public const string Province = "province";
+ public const string City = "city";
+
public const string CargoType = "cargoType";
public const string Warehouse = "warehouse";
diff --git a/src/Oms.Application/Shared/GeoPartitionMatcher.cs b/src/Oms.Application/Shared/GeoPartitionMatcher.cs
new file mode 100644
index 0000000..1aaae50
--- /dev/null
+++ b/src/Oms.Application/Shared/GeoPartitionMatcher.cs
@@ -0,0 +1,751 @@
+using System.Text.RegularExpressions;
+using Oms.Application.Dtos.Orders;
+using Oms.Domain.ChannelsAggregate.Entities;
+
+namespace Oms.Application.Shared;
+
+///
+/// 地理分区规则(价格/黑名单/偏远共用)。
+///
+public sealed class GeoRule
+{
+ public int Ordinal { get; init; }
+
+ public object? Tag { get; init; }
+
+ public string CountryCode { get; init; } = string.Empty;
+
+ /// zip / state / city / country
+ public string MatchDim { get; init; } = string.Empty;
+
+ /// prefix / exact / range / all
+ public string MatchMode { get; init; } = string.Empty;
+
+ public int PrefixLen { get; init; }
+
+ public string MatchTextStd { get; init; } = string.Empty;
+
+ public string ZipStartStd { get; init; } = string.Empty;
+
+ public string ZipEndStd { get; init; } = string.Empty;
+
+ public string MatchStateStd { get; init; } = string.Empty;
+}
+
+///
+/// 地址清洗、规则编译、单套分区匹配。价格/黑名单/偏远分区共用。
+///
+public static class GeoPartitionMatcher
+{
+ public const string DimZip = "zip";
+ public const string DimState = "state";
+ public const string DimCity = "city";
+ public const string DimCountry = "country";
+
+ public const string ModePrefix = "prefix";
+ public const string ModeExact = "exact";
+ public const string ModeRange = "range";
+ public const string ModeAll = "all";
+
+ private static readonly Regex PostcodeRangePattern = new(
+ @"^[\[(]\s*([^,)\]]*)\s*,\s*([^,)\]]*)\s*[\])]$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ public static string NormalizeCountry(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
+
+ public static string NormalizeZip(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ var chars = value.Where(ch => !char.IsWhiteSpace(ch) && ch != '-').ToArray();
+ return chars.Length == 0 ? string.Empty : new string(chars).ToUpperInvariant();
+ }
+
+ public static string NormalizeRegion(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
+
+ ///
+ /// 执行单套地理分区匹配。调用方需传入已排序规则:zip > state > city > country,同维度 prefixLen 降序。
+ ///
+ public static GeoRule? MatchSinglePartition(
+ string countryCode,
+ string zipStd,
+ string stateStd,
+ string cityStd,
+ IReadOnlyList ruleList)
+ {
+ var country = NormalizeCountry(countryCode);
+ zipStd ??= string.Empty;
+ stateStd ??= string.Empty;
+ cityStd ??= string.Empty;
+ if (ruleList == null || ruleList.Count == 0 || string.IsNullOrEmpty(country))
+ {
+ return null;
+ }
+
+ foreach (var rule in ruleList)
+ {
+ if (!string.Equals(rule.CountryCode, country, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (rule.MatchDim == DimZip)
+ {
+ if (string.IsNullOrEmpty(zipStd))
+ {
+ continue;
+ }
+
+ var hit = false;
+ if (rule.MatchMode == ModePrefix)
+ {
+ if (rule.PrefixLen > 0 && zipStd.Length >= rule.PrefixLen)
+ {
+ var userPrefix = zipStd[..rule.PrefixLen];
+ hit = userPrefix == rule.MatchTextStd;
+ }
+ }
+ else if (rule.MatchMode == ModeExact)
+ {
+ hit = zipStd == rule.MatchTextStd;
+ }
+ else if (rule.MatchMode == ModeRange)
+ {
+ hit = zipStd.CompareTo(rule.ZipStartStd) >= 0
+ && zipStd.CompareTo(rule.ZipEndStd) <= 0;
+ }
+
+ if (hit)
+ {
+ return rule;
+ }
+ }
+ else if (rule.MatchDim == DimState)
+ {
+ if (!string.IsNullOrEmpty(zipStd))
+ {
+ continue;
+ }
+
+ if (rule.MatchMode == ModeExact && stateStd == rule.MatchTextStd && !string.IsNullOrEmpty(stateStd))
+ {
+ return rule;
+ }
+ }
+ else if (rule.MatchDim == DimCity)
+ {
+ if (!string.IsNullOrEmpty(zipStd))
+ {
+ continue;
+ }
+
+ if (rule.MatchMode == ModeExact
+ && rule.MatchStateStd == stateStd
+ && rule.MatchTextStd == cityStd
+ && !string.IsNullOrEmpty(stateStd)
+ && !string.IsNullOrEmpty(cityStd))
+ {
+ return rule;
+ }
+ }
+ else if (rule.MatchDim == DimCountry)
+ {
+ return rule;
+ }
+ }
+
+ return null;
+ }
+
+ public static IReadOnlyList SortRules(IEnumerable rules) =>
+ rules
+ .OrderBy(DimRank)
+ .ThenByDescending(r => r.PrefixLen)
+ .ThenByDescending(RangeSpecificity)
+ .ThenBy(r => r.Ordinal)
+ .ToList();
+
+ public static GeoRule? Compile(KpiAreaInfo entity, int ordinal = 0, bool attachTag = true)
+ {
+ if (entity == null)
+ {
+ return null;
+ }
+
+ return Compile(
+ entity.CountryCode,
+ entity.Province,
+ entity.City,
+ entity.LeftPostcode,
+ entity.RightPostcode,
+ null,
+ entity.MatchDim,
+ entity.MatchMode,
+ entity.PrefixLen,
+ ordinal,
+ attachTag ? entity : null);
+ }
+
+ public static GeoRule? Compile(KpiAreaSnapshotRuleDto rule, int ordinal = 0, bool attachTag = true)
+ {
+ if (rule == null)
+ {
+ return null;
+ }
+
+ var range = KpiAreaSnapshotMapper.ResolvePostcodeRange(rule);
+ return Compile(
+ rule.CountryCode,
+ rule.Province,
+ rule.City,
+ rule.LeftPostcode,
+ rule.RightPostcode,
+ range,
+ rule.MatchDim,
+ rule.MatchMode,
+ rule.PrefixLen,
+ ordinal,
+ attachTag ? rule : null);
+ }
+
+ public static KpiAreaSnapshotRuleDto? MatchSnapshot(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode)
+ {
+ var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode);
+ return hit?.Tag as KpiAreaSnapshotRuleDto;
+ }
+
+ public static KpiAreaInfo? MatchEntity(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode)
+ {
+ var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode);
+ return hit?.Tag as KpiAreaInfo;
+ }
+
+ public static bool IsHit(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode) =>
+ MatchEntity(infos, countryCode, province, city, zipCode) != null;
+
+ public static bool IsHit(
+ IEnumerable? infos,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode) =>
+ MatchSnapshot(infos, countryCode, province, city, zipCode) != null;
+
+ public static GeoRule? Match(
+ IReadOnlyList compiled,
+ string countryCode,
+ string? province,
+ string? city,
+ string? zipCode)
+ {
+ var sorted = SortRules(compiled);
+ return MatchSinglePartition(
+ NormalizeCountry(countryCode),
+ NormalizeZip(zipCode),
+ NormalizeRegion(province),
+ NormalizeRegion(city),
+ sorted);
+ }
+
+ ///
+ /// 清洗并收口为一维规则,写入 MatchDim / MatchMode / PrefixLen。
+ /// 显式维度优先;未传则按字段推断。
+ ///
+ public static void PrepareEntity(KpiAreaInfo entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ entity.CountryCode = string.IsNullOrWhiteSpace(entity.CountryCode) ? null : entity.CountryCode.Trim();
+ entity.CountryName = string.IsNullOrWhiteSpace(entity.CountryName) ? null : entity.CountryName.Trim();
+ entity.Province = EmptyToNull(entity.Province);
+ entity.City = EmptyToNull(entity.City);
+ entity.LeftPostcode = EmptyToNull(entity.LeftPostcode);
+ entity.RightPostcode = EmptyToNull(entity.RightPostcode);
+
+ if (string.IsNullOrWhiteSpace(entity.CountryCode))
+ {
+ throw new InvalidOperationException("国家代码不能为空");
+ }
+
+ var dim = NormalizeDim(entity.MatchDim);
+ var mode = NormalizeMode(entity.MatchMode);
+ if (string.IsNullOrEmpty(dim))
+ {
+ dim = InferDim(entity.LeftPostcode, entity.RightPostcode, entity.Province, entity.City);
+ }
+
+ switch (dim)
+ {
+ case DimZip:
+ ApplyZipFields(entity, mode);
+ break;
+ case DimState:
+ if (string.IsNullOrWhiteSpace(entity.Province))
+ {
+ throw new InvalidOperationException("州/省匹配必须填写州/省");
+ }
+
+ entity.City = null;
+ entity.LeftPostcode = null;
+ entity.RightPostcode = null;
+ entity.MatchDim = DimState;
+ entity.MatchMode = ModeExact;
+ entity.PrefixLen = 0;
+ break;
+ case DimCity:
+ if (string.IsNullOrWhiteSpace(entity.Province) || string.IsNullOrWhiteSpace(entity.City))
+ {
+ throw new InvalidOperationException("城市匹配必须同时填写州/省");
+ }
+
+ entity.LeftPostcode = null;
+ entity.RightPostcode = null;
+ entity.MatchDim = DimCity;
+ entity.MatchMode = ModeExact;
+ entity.PrefixLen = 0;
+ break;
+ default:
+ entity.Province = null;
+ entity.City = null;
+ entity.LeftPostcode = null;
+ entity.RightPostcode = null;
+ entity.MatchDim = DimCountry;
+ entity.MatchMode = ModeAll;
+ entity.PrefixLen = 0;
+ break;
+ }
+ }
+
+ public static (string MatchDim, string MatchMode, int PrefixLen) Describe(KpiAreaInfo? entity)
+ {
+ if (entity == null)
+ {
+ return (DimCountry, ModeAll, 0);
+ }
+
+ if (!string.IsNullOrWhiteSpace(entity.MatchDim))
+ {
+ return (
+ NormalizeDim(entity.MatchDim) ?? DimCountry,
+ NormalizeMode(entity.MatchMode) ?? ModeAll,
+ entity.PrefixLen);
+ }
+
+ var rule = Compile(entity, attachTag: false);
+ if (rule == null)
+ {
+ return (DimCountry, ModeAll, 0);
+ }
+
+ return (rule.MatchDim, rule.MatchMode, rule.PrefixLen);
+ }
+
+ private static GeoRule? Compile(
+ string? countryCode,
+ string? province,
+ string? city,
+ string? leftPostcode,
+ string? rightPostcode,
+ string? postcodeRange,
+ string? matchDim,
+ string? matchMode,
+ int prefixLenHint,
+ int ordinal,
+ object? tag)
+ {
+ var country = NormalizeCountry(countryCode);
+ if (string.IsNullOrEmpty(country))
+ {
+ return null;
+ }
+
+ var dim = NormalizeDim(matchDim);
+ if (string.IsNullOrEmpty(dim))
+ {
+ dim = InferDim(leftPostcode, rightPostcode, province, city, postcodeRange);
+ }
+
+ var encoded = TryParseEncodedRange(postcodeRange);
+ var zipLeft = NormalizeZip(leftPostcode);
+ var zipRight = NormalizeZip(rightPostcode);
+ var zipMode = NormalizeMode(matchMode) ?? encoded?.Mode;
+ var zipText = encoded?.Text ?? string.Empty;
+ var zipStart = encoded?.Start ?? string.Empty;
+ var zipEnd = encoded?.End ?? string.Empty;
+ var prefixLen = prefixLenHint > 0 ? prefixLenHint : encoded?.PrefixLen ?? 0;
+
+ if (zipMode is not (ModePrefix or ModeExact or ModeRange))
+ {
+ zipMode = encoded?.Mode;
+ }
+
+ if (zipMode == null)
+ {
+ if (zipLeft.Length > 0 && zipRight.Length == 0)
+ {
+ zipMode = ModePrefix;
+ zipText = zipLeft;
+ prefixLen = zipLeft.Length;
+ }
+ else if (zipLeft.Length > 0 && zipLeft == zipRight)
+ {
+ zipMode = ModeExact;
+ zipText = zipLeft;
+ prefixLen = zipLeft.Length;
+ }
+ else if (zipLeft.Length > 0 && zipRight.Length > 0)
+ {
+ zipMode = ModeRange;
+ zipStart = zipLeft;
+ zipEnd = zipRight;
+ prefixLen = Math.Min(zipLeft.Length, zipRight.Length);
+ }
+ }
+ else if (dim == DimZip)
+ {
+ if (zipMode == ModePrefix)
+ {
+ zipText = zipLeft.Length > 0 ? zipLeft : zipText;
+ prefixLen = prefixLen > 0 ? prefixLen : zipText.Length;
+ }
+ else if (zipMode == ModeExact)
+ {
+ zipText = zipLeft.Length > 0 ? zipLeft : zipText;
+ prefixLen = zipText.Length;
+ }
+ else if (zipMode == ModeRange)
+ {
+ zipStart = zipLeft.Length > 0 ? zipLeft : zipStart;
+ zipEnd = zipRight.Length > 0 ? zipRight : zipEnd;
+ prefixLen = Math.Min(
+ zipStart.Length == 0 ? prefixLen : zipStart.Length,
+ zipEnd.Length == 0 ? prefixLen : zipEnd.Length);
+ }
+ }
+
+ if (dim == DimZip)
+ {
+ if (zipMode == null)
+ {
+ return null;
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimZip,
+ MatchMode = zipMode,
+ PrefixLen = prefixLen,
+ MatchTextStd = zipText,
+ ZipStartStd = zipStart,
+ ZipEndStd = zipEnd
+ };
+ }
+
+ var state = NormalizeRegion(province);
+ var cityStd = NormalizeRegion(city);
+ if (dim == DimCity)
+ {
+ if (string.IsNullOrEmpty(state) || string.IsNullOrEmpty(cityStd))
+ {
+ return null;
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimCity,
+ MatchMode = ModeExact,
+ MatchTextStd = cityStd,
+ MatchStateStd = state
+ };
+ }
+
+ if (dim == DimState)
+ {
+ if (string.IsNullOrEmpty(state))
+ {
+ return null;
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimState,
+ MatchMode = ModeExact,
+ MatchTextStd = state
+ };
+ }
+
+ return new GeoRule
+ {
+ Ordinal = ordinal,
+ Tag = tag,
+ CountryCode = country,
+ MatchDim = DimCountry,
+ MatchMode = ModeAll
+ };
+ }
+
+ private static void ApplyZipFields(KpiAreaInfo entity, string? requestedMode)
+ {
+ entity.Province = null;
+ entity.City = null;
+ var zipLeft = NormalizeZip(entity.LeftPostcode);
+ var zipRight = NormalizeZip(entity.RightPostcode);
+ if (zipLeft.Length == 0 && zipRight.Length > 0)
+ {
+ zipLeft = zipRight;
+ zipRight = string.Empty;
+ }
+
+ if (zipLeft.Length == 0)
+ {
+ throw new InvalidOperationException("邮编匹配必须填写起始邮编");
+ }
+
+ var mode = requestedMode;
+ if (mode is not (ModePrefix or ModeExact or ModeRange))
+ {
+ if (zipRight.Length == 0)
+ {
+ mode = ModePrefix;
+ }
+ else if (zipLeft == zipRight)
+ {
+ mode = ModeExact;
+ }
+ else
+ {
+ mode = ModeRange;
+ }
+ }
+
+ if (mode == ModePrefix)
+ {
+ entity.LeftPostcode = zipLeft;
+ entity.RightPostcode = null;
+ entity.PrefixLen = zipLeft.Length;
+ }
+ else if (mode == ModeExact)
+ {
+ entity.LeftPostcode = zipLeft;
+ entity.RightPostcode = zipLeft;
+ entity.PrefixLen = zipLeft.Length;
+ }
+ else
+ {
+ if (zipRight.Length == 0)
+ {
+ throw new InvalidOperationException("邮编区间必须填写截止邮编");
+ }
+
+ if (zipLeft.CompareTo(zipRight) > 0)
+ {
+ throw new InvalidOperationException("起始邮编不能大于截止邮编");
+ }
+
+ entity.LeftPostcode = zipLeft;
+ entity.RightPostcode = zipRight;
+ entity.PrefixLen = Math.Min(zipLeft.Length, zipRight.Length);
+ }
+
+ entity.MatchDim = DimZip;
+ entity.MatchMode = mode;
+ }
+
+ private static string InferDim(
+ string? leftPostcode,
+ string? rightPostcode,
+ string? province,
+ string? city,
+ string? postcodeRange = null)
+ {
+ if (!string.IsNullOrWhiteSpace(postcodeRange)
+ || !string.IsNullOrWhiteSpace(leftPostcode)
+ || !string.IsNullOrWhiteSpace(rightPostcode))
+ {
+ return DimZip;
+ }
+
+ if (!string.IsNullOrWhiteSpace(city))
+ {
+ return DimCity;
+ }
+
+ if (!string.IsNullOrWhiteSpace(province))
+ {
+ return DimState;
+ }
+
+ return DimCountry;
+ }
+
+ private static string? NormalizeDim(string? value)
+ {
+ var raw = value?.Trim().ToLowerInvariant();
+ return raw switch
+ {
+ DimZip or "postcode" or "postal" or "邮编" => DimZip,
+ DimState or "province" or "州" or "州/省" or "省" => DimState,
+ DimCity or "城市" => DimCity,
+ DimCountry or "国家" or "仅国家" => DimCountry,
+ _ => null
+ };
+ }
+
+ private static string? NormalizeMode(string? value)
+ {
+ var raw = value?.Trim().ToLowerInvariant();
+ return raw switch
+ {
+ ModePrefix or "前缀" => ModePrefix,
+ ModeExact or "精确" or "exactmatch" => ModeExact,
+ ModeRange or "区间" => ModeRange,
+ ModeAll or "全部" => ModeAll,
+ _ => null
+ };
+ }
+
+ private static EncodedZip? TryParseEncodedRange(string? postcodeRange)
+ {
+ if (string.IsNullOrWhiteSpace(postcodeRange))
+ {
+ return null;
+ }
+
+ var raw = postcodeRange.Trim();
+ if (raw.StartsWith("prefix:", StringComparison.OrdinalIgnoreCase))
+ {
+ var text = NormalizeZip(raw["prefix:".Length..]);
+ if (text.Length == 0)
+ {
+ return null;
+ }
+
+ return new EncodedZip(ModePrefix, text, string.Empty, string.Empty, text.Length);
+ }
+
+ if (raw.StartsWith("exact:", StringComparison.OrdinalIgnoreCase))
+ {
+ var text = NormalizeZip(raw["exact:".Length..]);
+ if (text.Length == 0)
+ {
+ return null;
+ }
+
+ return new EncodedZip(ModeExact, text, string.Empty, string.Empty, text.Length);
+ }
+
+ var match = PostcodeRangePattern.Match(raw);
+ if (!match.Success)
+ {
+ return null;
+ }
+
+ var left = NormalizeZip(match.Groups[1].Value);
+ var right = NormalizeZip(match.Groups[2].Value);
+ if (left.Length == 0 && right.Length == 0)
+ {
+ return null;
+ }
+
+ if (left.Length == 0)
+ {
+ left = right;
+ }
+
+ if (right.Length == 0)
+ {
+ return new EncodedZip(ModePrefix, left, string.Empty, string.Empty, left.Length);
+ }
+
+ if (left == right)
+ {
+ return new EncodedZip(ModeExact, left, string.Empty, string.Empty, left.Length);
+ }
+
+ return new EncodedZip(ModeRange, string.Empty, left, right, Math.Min(left.Length, right.Length));
+ }
+
+ private static List CompileList(IEnumerable? sources, Func compile)
+ {
+ var list = new List();
+ if (sources == null)
+ {
+ return list;
+ }
+
+ var ordinal = 0;
+ foreach (var source in sources)
+ {
+ var rule = compile(source, ordinal++, true);
+ if (rule != null)
+ {
+ list.Add(rule);
+ }
+ }
+
+ return list;
+ }
+
+ private static int DimRank(GeoRule rule) =>
+ rule.MatchDim switch
+ {
+ DimZip => 1,
+ DimState => 2,
+ DimCity => 3,
+ DimCountry => 4,
+ _ => 9
+ };
+
+ private static int RangeSpecificity(GeoRule rule)
+ {
+ if (rule.MatchDim != DimZip)
+ {
+ return 0;
+ }
+
+ if (rule.MatchMode == ModeExact)
+ {
+ return rule.MatchTextStd.Length * 10;
+ }
+
+ if (rule.MatchMode == ModeRange)
+ {
+ return Math.Min(rule.ZipStartStd.Length, rule.ZipEndStd.Length);
+ }
+
+ return 0;
+ }
+
+ private static string? EmptyToNull(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ private sealed record EncodedZip(string Mode, string Text, string Start, string End, int PrefixLen);
+}
diff --git a/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs
index 7377412..2ebfabf 100644
--- a/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs
+++ b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs
@@ -36,6 +36,14 @@ public static class KpiAreaInfoImportHelper
["RightPostcode"] = nameof(KpiAreaInfo.RightPostcode),
["rightPostcode"] = nameof(KpiAreaInfo.RightPostcode),
["rightPostCode"] = nameof(KpiAreaInfo.RightPostcode),
+ ["匹配维度"] = nameof(KpiAreaInfo.MatchDim),
+ ["MatchDim"] = nameof(KpiAreaInfo.MatchDim),
+ ["matchDim"] = nameof(KpiAreaInfo.MatchDim),
+ ["匹配方式"] = nameof(KpiAreaInfo.MatchMode),
+ ["MatchMode"] = nameof(KpiAreaInfo.MatchMode),
+ ["matchMode"] = nameof(KpiAreaInfo.MatchMode),
+ ["前缀长度"] = nameof(KpiAreaInfo.PrefixLen),
+ ["PrefixLen"] = nameof(KpiAreaInfo.PrefixLen),
};
public static async Task BuildTemplateBytesAsync()
@@ -46,10 +54,34 @@ public static class KpiAreaInfoImportHelper
{
["国家代码"] = "US",
["国家名称"] = "美国",
- ["州/省"] = "CA",
+ ["匹配维度"] = "zip",
+ ["匹配方式"] = "range",
+ ["州/省"] = "",
["城市"] = "",
["起始邮编"] = "90000",
["截止邮编"] = "90199"
+ },
+ new()
+ {
+ ["国家代码"] = "US",
+ ["国家名称"] = "美国",
+ ["匹配维度"] = "zip",
+ ["匹配方式"] = "prefix",
+ ["州/省"] = "",
+ ["城市"] = "",
+ ["起始邮编"] = "90",
+ ["截止邮编"] = ""
+ },
+ new()
+ {
+ ["国家代码"] = "US",
+ ["国家名称"] = "美国",
+ ["匹配维度"] = "state",
+ ["匹配方式"] = "exact",
+ ["州/省"] = "CA",
+ ["城市"] = "",
+ ["起始邮编"] = "",
+ ["截止邮编"] = ""
}
};
@@ -134,7 +166,12 @@ public static class KpiAreaInfoImportHelper
Province = mapped.GetValueOrDefault(nameof(KpiAreaInfo.Province)),
City = mapped.GetValueOrDefault(nameof(KpiAreaInfo.City)),
LeftPostcode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.LeftPostcode)),
- RightPostcode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.RightPostcode))
+ RightPostcode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.RightPostcode)),
+ MatchDim = mapped.GetValueOrDefault(nameof(KpiAreaInfo.MatchDim)),
+ MatchMode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.MatchMode)),
+ PrefixLen = int.TryParse(mapped.GetValueOrDefault(nameof(KpiAreaInfo.PrefixLen)), out var prefixLen)
+ ? prefixLen
+ : 0
};
}
@@ -177,13 +214,7 @@ public static class KpiAreaInfoImportHelper
ValidatePostcode(entity.LeftPostcode, "起始邮编");
ValidatePostcode(entity.RightPostcode, "截止邮编");
-
- var hasLeft = !string.IsNullOrWhiteSpace(entity.LeftPostcode);
- var hasRight = !string.IsNullOrWhiteSpace(entity.RightPostcode);
- if (hasLeft != hasRight)
- {
- throw new InvalidOperationException("起始邮编与截止邮编需同时填写或同时留空");
- }
+ GeoPartitionMatcher.PrepareEntity(entity);
}
private static void ValidatePostcode(string? value, string label)
diff --git a/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs b/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs
index 166cf03..8644700 100644
--- a/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs
+++ b/src/Oms.Application/Shared/KpiAreaSnapshotMapper.cs
@@ -1,18 +1,13 @@
-using System.Text.RegularExpressions;
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Application.Shared;
///
-/// KPI 分区快照映射与左开右闭邮编匹配。
+/// KPI 分区快照映射。邮编区间为闭区间,prefix/exact/range 编码进 PostcodeRange。
///
public static class KpiAreaSnapshotMapper
{
- private static readonly Regex PostcodeRangePattern = new(
- @"^\(\s*([^,)\]]*)\s*,\s*([^,)\]]*)\s*\]$",
- RegexOptions.Compiled | RegexOptions.CultureInvariant);
-
public static KpiAreaSnapshotRuleDto MapToSnapshotRule(KpiAreaInfo entity) =>
new()
{
@@ -20,17 +15,37 @@ public static class KpiAreaSnapshotMapper
CountryCode = entity.CountryCode,
Province = entity.Province,
City = entity.City,
+ MatchDim = entity.MatchDim,
+ MatchMode = entity.MatchMode,
+ PrefixLen = entity.PrefixLen,
PostcodeRange = BuildPostcodeRange(entity.LeftPostcode, entity.RightPostcode)
};
public static string? BuildPostcodeRange(string? left, string? right)
{
- if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right))
+ var zipLeft = GeoPartitionMatcher.NormalizeZip(left);
+ var zipRight = GeoPartitionMatcher.NormalizeZip(right);
+ if (zipLeft.Length == 0 && zipRight.Length == 0)
{
return null;
}
- return $"({left?.Trim()},{right?.Trim()}]";
+ if (zipLeft.Length > 0 && zipRight.Length == 0)
+ {
+ return $"prefix:{zipLeft}";
+ }
+
+ if (zipLeft.Length == 0 && zipRight.Length > 0)
+ {
+ return $"prefix:{zipRight}";
+ }
+
+ if (zipLeft == zipRight)
+ {
+ return $"exact:{zipLeft}";
+ }
+
+ return $"[{zipLeft},{zipRight}]";
}
public static string? ResolvePostcodeRange(KpiAreaSnapshotRuleDto rule)
@@ -48,58 +63,6 @@ public static class KpiAreaSnapshotMapper
string countryCode,
string? province,
string? city,
- string? zipCode)
- {
- if (!string.IsNullOrWhiteSpace(rule.CountryCode)
- && !string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.Province)
- && !string.Equals(rule.Province, province, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.City)
- && !string.Equals(rule.City, city, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- var postcodeRange = ResolvePostcodeRange(rule);
- if (string.IsNullOrWhiteSpace(postcodeRange))
- {
- return true;
- }
-
- if (string.IsNullOrWhiteSpace(zipCode))
- {
- return false;
- }
-
- return IsZipInLeftOpenRightClosedRange(zipCode.Trim(), postcodeRange);
- }
-
- public static bool IsZipInLeftOpenRightClosedRange(string zipCode, string postcodeRange)
- {
- var match = PostcodeRangePattern.Match(postcodeRange.Trim());
- if (!match.Success)
- {
- return false;
- }
-
- var left = match.Groups[1].Value.Trim();
- var right = match.Groups[2].Value.Trim();
- if (long.TryParse(zipCode, out var zip)
- && long.TryParse(left, out var leftValue)
- && long.TryParse(right, out var rightValue))
- {
- return zip > leftValue && zip <= rightValue;
- }
-
- return string.Compare(zipCode, left, StringComparison.OrdinalIgnoreCase) > 0
- && string.Compare(zipCode, right, StringComparison.OrdinalIgnoreCase) <= 0;
- }
+ string? zipCode) =>
+ GeoPartitionMatcher.IsHit([rule], countryCode, province, city, zipCode);
}
diff --git a/src/Oms.Application/Shared/RulesSnapshotHelper.cs b/src/Oms.Application/Shared/RulesSnapshotHelper.cs
index fc5744c..88f9062 100644
--- a/src/Oms.Application/Shared/RulesSnapshotHelper.cs
+++ b/src/Oms.Application/Shared/RulesSnapshotHelper.cs
@@ -64,12 +64,7 @@ public static class RulesSnapshotHelper
return false;
}
- return infos.Any(rule => KpiAreaSnapshotMapper.IsRuleMatched(
- rule,
- countryCode,
- province,
- city,
- zipCode));
+ return GeoPartitionMatcher.IsHit(infos, countryCode, province, city, zipCode);
}
///
diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs b/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs
index 3c5d130..0e016fa 100644
--- a/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs
+++ b/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaInfo.cs
@@ -19,4 +19,12 @@ public class KpiAreaInfo : OmsAuditedEntity
public string? LeftPostcode { get; set; }
public string? RightPostcode { get; set; }
+
+ /// zip / state / city / country
+ public string? MatchDim { get; set; }
+
+ /// prefix / exact / range / all
+ public string? MatchMode { get; set; }
+
+ public int PrefixLen { get; set; }
}
diff --git a/src/Oms.Tests/GeoPartitionMatcherTests.cs b/src/Oms.Tests/GeoPartitionMatcherTests.cs
new file mode 100644
index 0000000..567998a
--- /dev/null
+++ b/src/Oms.Tests/GeoPartitionMatcherTests.cs
@@ -0,0 +1,145 @@
+using FluentAssertions;
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Shared;
+using Oms.Domain.ChannelsAggregate.Entities;
+
+namespace Oms.Tests;
+
+public class GeoPartitionMatcherTests
+{
+ [Fact]
+ public void ZipPrefix_LongerPrefixWins()
+ {
+ var rules = CompileEntities(
+ Zip("US", "9"),
+ Zip("US", "90"));
+
+ var hit = GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LA", "90001");
+ hit!.LeftPostcode.Should().Be("90");
+ }
+
+ [Fact]
+ public void ZipPresent_ShouldSkipStateAndCity()
+ {
+ var rules = CompileEntities(
+ State("US", "CA"),
+ City("US", "CA", "LA"));
+
+ GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LA", "90001").Should().BeNull();
+ }
+
+ [Fact]
+ public void ZipEmpty_StateExact_ShouldHit()
+ {
+ var rules = CompileEntities(
+ Zip("US", "90"),
+ State("US", "CA"));
+
+ var hit = GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LA", null);
+ hit!.Province.Should().Be("CA");
+ hit.LeftPostcode.Should().BeNull();
+ }
+
+ [Fact]
+ public void ZipEmpty_CityRequiresState()
+ {
+ var rules = CompileEntities(City("US", "CA", "LOSANGELES"));
+
+ GeoPartitionMatcher.MatchEntity(rules, "US", "NY", "LOSANGELES", null).Should().BeNull();
+ GeoPartitionMatcher.MatchEntity(rules, "US", "CA", "LOSANGELES", null).Should().NotBeNull();
+ }
+
+ [Fact]
+ public void ZipRange_ShouldBeClosedInterval()
+ {
+ var rules = CompileEntities(Range("US", "90000", "90199"));
+
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "90000").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "90199").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(rules, "US", null, null, "90200").Should().BeFalse();
+ }
+
+ [Fact]
+ public void CountryFallback_ShouldHitWhenZipMisses()
+ {
+ var rules = CompileEntities(
+ Range("US", "10000", "19999"),
+ CountryOnly("US"));
+
+ var hit = GeoPartitionMatcher.MatchEntity(rules, "US", "CA", null, "90001");
+ hit!.LeftPostcode.Should().BeNull();
+ hit.Province.Should().BeNull();
+ }
+
+ [Fact]
+ public void ThreePartitions_ShouldNotInterfere()
+ {
+ var black = CompileEntities(Range("US", "90000", "90010"));
+ var remote = CompileEntities(Range("US", "05999", "06999"));
+ var price = CompileEntities(Zip("US", "90"));
+
+ GeoPartitionMatcher.IsHit(black, "US", null, null, "90005").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(remote, "US", null, null, "90005").Should().BeFalse();
+ GeoPartitionMatcher.MatchEntity(price, "US", null, null, "90005")!.LeftPostcode.Should().Be("90");
+ }
+
+ [Fact]
+ public void SnapshotLegacyLeftOpenFormat_ShouldParseAsClosed()
+ {
+ var infos = new List
+ {
+ new() { Id = 1, CountryCode = "US", PostcodeRange = "(00000,50000]" }
+ };
+
+ GeoPartitionMatcher.IsHit(infos, "US", null, null, "00000").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(infos, "US", null, null, "50000").Should().BeTrue();
+ GeoPartitionMatcher.IsHit(infos, "US", null, null, "50001").Should().BeFalse();
+ }
+
+ [Fact]
+ public void PrepareEntity_PrefixKeepsRightEmpty()
+ {
+ var entity = new KpiAreaInfo { CountryCode = "us", LeftPostcode = "90" };
+ GeoPartitionMatcher.PrepareEntity(entity);
+ entity.LeftPostcode.Should().Be("90");
+ entity.RightPostcode.Should().BeNull();
+ entity.MatchDim.Should().Be("zip");
+ entity.MatchMode.Should().Be("prefix");
+ entity.PrefixLen.Should().Be(2);
+ GeoPartitionMatcher.Describe(entity).MatchDim.Should().Be("zip");
+ GeoPartitionMatcher.Describe(entity).MatchMode.Should().Be("prefix");
+ }
+
+ [Fact]
+ public void PrepareEntity_CityWithoutState_ShouldThrow()
+ {
+ var entity = new KpiAreaInfo { CountryCode = "US", City = "LA" };
+ var act = () => GeoPartitionMatcher.PrepareEntity(entity);
+ act.Should().Throw().WithMessage("*州*");
+ }
+
+ private static List CompileEntities(params KpiAreaInfo[] items)
+ {
+ foreach (var item in items)
+ {
+ GeoPartitionMatcher.PrepareEntity(item);
+ }
+
+ return items.ToList();
+ }
+
+ private static KpiAreaInfo Zip(string country, string prefix) =>
+ new() { CountryCode = country, LeftPostcode = prefix };
+
+ private static KpiAreaInfo Range(string country, string left, string right) =>
+ new() { CountryCode = country, LeftPostcode = left, RightPostcode = right };
+
+ private static KpiAreaInfo State(string country, string state) =>
+ new() { CountryCode = country, Province = state };
+
+ private static KpiAreaInfo City(string country, string state, string city) =>
+ new() { CountryCode = country, Province = state, City = city };
+
+ private static KpiAreaInfo CountryOnly(string country) =>
+ new() { CountryCode = country };
+}
diff --git a/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs
index 1adc5ed..2f62e90 100644
--- a/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs
+++ b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs
@@ -27,17 +27,18 @@ public class KpiAreaInfoImportHelperTests
}
[Fact]
- public void PrepareEntityForImport_MissingPostcodePair_ShouldThrow()
+ public void PrepareEntityForImport_PrefixOnlyLeft_ShouldSucceed()
{
var entity = new KpiAreaInfo
{
CountryCode = "US",
CountryName = "美国",
- LeftPostcode = "90000"
+ LeftPostcode = "90"
};
- var act = () => KpiAreaInfoImportHelper.PrepareEntityForImport(entity, new Dictionary());
+ KpiAreaInfoImportHelper.PrepareEntityForImport(entity, new Dictionary());
- act.Should().Throw().WithMessage("*截止邮编*");
+ entity.LeftPostcode.Should().Be("90");
+ entity.RightPostcode.Should().BeNull();
}
}
diff --git a/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs b/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs
index a612862..58f5cf4 100644
--- a/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs
+++ b/src/Oms.Tests/KpiAreaSnapshotMapperTests.cs
@@ -5,19 +5,21 @@ namespace Oms.Tests;
public class KpiAreaSnapshotMapperTests
{
- [Theory]
- [InlineData("06000", "(00000,50000]", true)]
- [InlineData("00000", "(00000,50000]", false)]
- [InlineData("50000", "(00000,50000]", true)]
- [InlineData("50001", "(00000,50000]", false)]
- public void IsZipInLeftOpenRightClosedRange_ShouldMatchExpected(string zip, string range, bool expected)
+ [Fact]
+ public void BuildPostcodeRange_ShouldEncodeClosedRange()
{
- KpiAreaSnapshotMapper.IsZipInLeftOpenRightClosedRange(zip, range).Should().Be(expected);
+ KpiAreaSnapshotMapper.BuildPostcodeRange("00000", "50000").Should().Be("[00000,50000]");
}
[Fact]
- public void BuildPostcodeRange_ShouldFormatLeftOpenRightClosed()
+ public void BuildPostcodeRange_EqualBounds_ShouldEncodeExact()
{
- KpiAreaSnapshotMapper.BuildPostcodeRange("00000", "50000").Should().Be("(00000,50000]");
+ KpiAreaSnapshotMapper.BuildPostcodeRange("90001", "90001").Should().Be("exact:90001");
+ }
+
+ [Fact]
+ public void BuildPostcodeRange_LeftOnly_ShouldEncodePrefix()
+ {
+ KpiAreaSnapshotMapper.BuildPostcodeRange("90", null).Should().Be("prefix:90");
}
}