From 74a71991b28fcde2db7abbd033a53f655e1f5f6b Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 5 Oct 2026 16:12:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E4=BB=B7=E6=A0=BC/?= =?UTF-8?q?=E9=BB=91=E5=90=8D=E5=8D=95/=E5=81=8F=E8=BF=9C=E5=88=86?= =?UTF-8?q?=E5=8C=BA=E5=8C=B9=E9=85=8D=EF=BC=8C=E5=B9=B6=E8=90=BD=E5=BA=93?= =?UTF-8?q?=20MatchDim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- db.sql | 6 +- sql/admin.sql | 361 +++++++++ sql/migrate_kpi_area_info_match.sql | 66 ++ .../Dtos/Channels/KpiAreaInfoOutput.cs | 15 + .../Dtos/Channels/KpiAreaInfoSaveInput.cs | 15 + .../Dtos/Orders/OrderPriceCheckInput.cs | 5 + .../Dtos/Orders/ProductBillingSnapshotDtos.cs | 6 +- .../Dtos/Orders/PublishSnapshotRuleDtos.cs | 10 +- .../Services/Channels/KpiAreaService.cs | 31 +- .../SalesProductService.BillingSimulate.cs | 9 +- .../Orders/OrderService.ParcelBilling.cs | 31 +- .../Orders/OrderService.ParcelQuote.cs | 10 +- .../Orders/ParcelBillingCalculator.cs | 63 +- .../Services/Orders/PriceCheckHintBuilder.cs | 6 +- .../Services/Orders/PriceCheckInputProfile.cs | 6 + .../Shared/GeoPartitionMatcher.cs | 751 ++++++++++++++++++ .../Shared/KpiAreaInfoImportHelper.cs | 49 +- .../Shared/KpiAreaSnapshotMapper.cs | 87 +- .../Shared/RulesSnapshotHelper.cs | 7 +- .../ChannelsAggregate/Entities/KpiAreaInfo.cs | 8 + src/Oms.Tests/GeoPartitionMatcherTests.cs | 145 ++++ src/Oms.Tests/KpiAreaInfoImportHelperTests.cs | 9 +- src/Oms.Tests/KpiAreaSnapshotMapperTests.cs | 20 +- 23 files changed, 1526 insertions(+), 190 deletions(-) create mode 100644 sql/admin.sql create mode 100644 sql/migrate_kpi_area_info_match.sql create mode 100644 src/Oms.Application/Shared/GeoPartitionMatcher.cs create mode 100644 src/Oms.Tests/GeoPartitionMatcherTests.cs 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"); } }