feat: 统一价格/黑名单/偏远分区匹配,并落库 MatchDim
All checks were successful
Build and Deploy / deploy (push) Successful in 1m2s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m2s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b17e6639a4
commit
74a71991b2
6
db.sql
6
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` (
|
||||
|
||||
361
sql/admin.sql
Normal file
361
sql/admin.sql
Normal file
@ -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`, '') <> ''
|
||||
);
|
||||
66
sql/migrate_kpi_area_info_match.sql
Normal file
66
sql/migrate_kpi_area_info_match.sql
Normal file
@ -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`, '') <> ''
|
||||
);
|
||||
@ -45,6 +45,21 @@ public class KpiAreaInfoOutput
|
||||
/// </summary>
|
||||
public string? RightPostcode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 匹配维度:zip / state / city / country。
|
||||
/// </summary>
|
||||
public string? MatchDim { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 匹配方式:prefix / exact / range / all。
|
||||
/// </summary>
|
||||
public string? MatchMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮编前缀长度(zip+prefix)。
|
||||
/// </summary>
|
||||
public int PrefixLen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
|
||||
@ -44,4 +44,19 @@ public class KpiAreaInfoSaveInput
|
||||
/// 结束邮编。
|
||||
/// </summary>
|
||||
public string? RightPostcode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 匹配维度:zip / state / city / country。
|
||||
/// </summary>
|
||||
public string? MatchDim { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 匹配方式:prefix / exact / range / all。
|
||||
/// </summary>
|
||||
public string? MatchMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮编前缀长度。
|
||||
/// </summary>
|
||||
public int? PrefixLen { get; set; }
|
||||
}
|
||||
|
||||
@ -55,6 +55,11 @@ public class OrderPriceCheckInput
|
||||
/// </summary>
|
||||
public string? DestinationProvince { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目的城市。
|
||||
/// </summary>
|
||||
public string? DestinationCity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮编。
|
||||
/// </summary>
|
||||
|
||||
@ -23,7 +23,7 @@ public class SalesProductPricePublishSnapshotDto
|
||||
public string? PriceAreaName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
|
||||
/// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为闭区间。
|
||||
/// </summary>
|
||||
public List<KpiAreaSnapshotRuleDto> PriceAreaInfos { get; set; } = [];
|
||||
|
||||
@ -59,7 +59,7 @@ public class SalesProductRulesPublishSnapshotDto
|
||||
public string? BlacklistAreaName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
|
||||
/// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。
|
||||
/// </summary>
|
||||
public List<KpiAreaSnapshotRuleDto> BlacklistAreaInfos { get; set; } = [];
|
||||
|
||||
@ -74,7 +74,7 @@ public class SalesProductRulesPublishSnapshotDto
|
||||
public string? RemoteAreaName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为左开右闭 (left,right]。
|
||||
/// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。
|
||||
/// </summary>
|
||||
public List<KpiAreaSnapshotRuleDto> RemoteAreaInfos { get; set; } = [];
|
||||
|
||||
|
||||
@ -17,8 +17,16 @@ public class KpiAreaSnapshotRuleDto
|
||||
|
||||
public string? City { get; set; }
|
||||
|
||||
/// <summary>zip / state / city / country,发布时固化。</summary>
|
||||
public string? MatchDim { get; set; }
|
||||
|
||||
/// <summary>prefix / exact / range / all,发布时固化。</summary>
|
||||
public string? MatchMode { get; set; }
|
||||
|
||||
public int PrefixLen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮编区间,左开右闭,如 (50000,90000]。
|
||||
/// 邮编匹配编码:prefix:900 / exact:90001 / [90000,90199];旧快照 (left,right] 按闭区间解析。
|
||||
/// </summary>
|
||||
public string? PostcodeRange { get; set; }
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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)
|
||||
};
|
||||
|
||||
@ -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<string?> ResolveSimulateWarehouseNameAsync(int? warehouseId)
|
||||
|
||||
@ -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<bool> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -56,9 +56,11 @@ internal static class PriceCheckHintBuilder
|
||||
PriceCheckMissingFieldCodes.Dimensions =>
|
||||
"未填写包裹尺寸:计费重按实重估算,入库实测尺寸后价格可能变化。",
|
||||
PriceCheckMissingFieldCodes.PostalCode =>
|
||||
"未填写邮编:未校验黑名单/偏远/精确分区,报价仅供参考。",
|
||||
"未填写邮编:优先按州/市或国家级分区匹配,精确邮编可能调整价格。",
|
||||
PriceCheckMissingFieldCodes.Province =>
|
||||
"未填写省州:分区按国家级匹配,精确地址可能调整价格。",
|
||||
"未填写省州:无邮编时无法按州/市降级匹配,分区可能按国家级兜底。",
|
||||
PriceCheckMissingFieldCodes.City =>
|
||||
"未填写城市:无邮编且州未命中时无法按城市匹配。",
|
||||
PriceCheckMissingFieldCodes.CargoType =>
|
||||
"未选择货物属性:已分别试算普货/带电/敏感货等,下单时以所选属性为准。",
|
||||
PriceCheckMissingFieldCodes.Warehouse =>
|
||||
|
||||
@ -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";
|
||||
|
||||
751
src/Oms.Application/Shared/GeoPartitionMatcher.cs
Normal file
751
src/Oms.Application/Shared/GeoPartitionMatcher.cs
Normal file
@ -0,0 +1,751 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Domain.ChannelsAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 地理分区规则(价格/黑名单/偏远共用)。
|
||||
/// </summary>
|
||||
public sealed class GeoRule
|
||||
{
|
||||
public int Ordinal { get; init; }
|
||||
|
||||
public object? Tag { get; init; }
|
||||
|
||||
public string CountryCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>zip / state / city / country</summary>
|
||||
public string MatchDim { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>prefix / exact / range / all</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 地址清洗、规则编译、单套分区匹配。价格/黑名单/偏远分区共用。
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// 执行单套地理分区匹配。调用方需传入已排序规则:zip > state > city > country,同维度 prefixLen 降序。
|
||||
/// </summary>
|
||||
public static GeoRule? MatchSinglePartition(
|
||||
string countryCode,
|
||||
string zipStd,
|
||||
string stateStd,
|
||||
string cityStd,
|
||||
IReadOnlyList<GeoRule> 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<GeoRule> SortRules(IEnumerable<GeoRule> 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<KpiAreaSnapshotRuleDto>? 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<KpiAreaInfo>? 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<KpiAreaInfo>? infos,
|
||||
string countryCode,
|
||||
string? province,
|
||||
string? city,
|
||||
string? zipCode) =>
|
||||
MatchEntity(infos, countryCode, province, city, zipCode) != null;
|
||||
|
||||
public static bool IsHit(
|
||||
IEnumerable<KpiAreaSnapshotRuleDto>? infos,
|
||||
string countryCode,
|
||||
string? province,
|
||||
string? city,
|
||||
string? zipCode) =>
|
||||
MatchSnapshot(infos, countryCode, province, city, zipCode) != null;
|
||||
|
||||
public static GeoRule? Match(
|
||||
IReadOnlyList<GeoRule> compiled,
|
||||
string countryCode,
|
||||
string? province,
|
||||
string? city,
|
||||
string? zipCode)
|
||||
{
|
||||
var sorted = SortRules(compiled);
|
||||
return MatchSinglePartition(
|
||||
NormalizeCountry(countryCode),
|
||||
NormalizeZip(zipCode),
|
||||
NormalizeRegion(province),
|
||||
NormalizeRegion(city),
|
||||
sorted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清洗并收口为一维规则,写入 MatchDim / MatchMode / PrefixLen。
|
||||
/// 显式维度优先;未传则按字段推断。
|
||||
/// </summary>
|
||||
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<GeoRule> CompileList<T>(IEnumerable<T>? sources, Func<T, int, bool, GeoRule?> compile)
|
||||
{
|
||||
var list = new List<GeoRule>();
|
||||
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);
|
||||
}
|
||||
@ -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<byte[]> 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)
|
||||
|
||||
@ -1,18 +1,13 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Domain.ChannelsAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// KPI 分区快照映射与左开右闭邮编匹配。
|
||||
/// KPI 分区快照映射。邮编区间为闭区间,prefix/exact/range 编码进 PostcodeRange。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -19,4 +19,12 @@ public class KpiAreaInfo : OmsAuditedEntity
|
||||
public string? LeftPostcode { get; set; }
|
||||
|
||||
public string? RightPostcode { get; set; }
|
||||
|
||||
/// <summary>zip / state / city / country</summary>
|
||||
public string? MatchDim { get; set; }
|
||||
|
||||
/// <summary>prefix / exact / range / all</summary>
|
||||
public string? MatchMode { get; set; }
|
||||
|
||||
public int PrefixLen { get; set; }
|
||||
}
|
||||
|
||||
145
src/Oms.Tests/GeoPartitionMatcherTests.cs
Normal file
145
src/Oms.Tests/GeoPartitionMatcherTests.cs
Normal file
@ -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<KpiAreaSnapshotRuleDto>
|
||||
{
|
||||
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<InvalidOperationException>().WithMessage("*州*");
|
||||
}
|
||||
|
||||
private static List<KpiAreaInfo> 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 };
|
||||
}
|
||||
@ -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<string, Country>());
|
||||
KpiAreaInfoImportHelper.PrepareEntityForImport(entity, new Dictionary<string, Country>());
|
||||
|
||||
act.Should().Throw<InvalidOperationException>().WithMessage("*截止邮编*");
|
||||
entity.LeftPostcode.Should().Be("90");
|
||||
entity.RightPostcode.Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user