feat: 统一价格/黑名单/偏远分区匹配,并落库 MatchDim
All checks were successful
Build and Deploy / deploy (push) Successful in 1m2s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-05 16:12:09 +08:00
parent b17e6639a4
commit 74a71991b2
23 changed files with 1526 additions and 190 deletions

6
db.sql
View File

@ -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
View 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`, '') <> ''
);

View 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`, '') <> ''
);

View File

@ -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>

View File

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

View File

@ -55,6 +55,11 @@ public class OrderPriceCheckInput
/// </summary>
public string? DestinationProvince { get; set; }
/// <summary>
/// 目的城市。
/// </summary>
public string? DestinationCity { get; set; }
/// <summary>
/// 邮编。
/// </summary>

View File

@ -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; } = [];

View File

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

View File

@ -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)
};

View File

@ -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)

View File

@ -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);
}
}

View File

@ -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,

View File

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

View File

@ -56,9 +56,11 @@ internal static class PriceCheckHintBuilder
PriceCheckMissingFieldCodes.Dimensions =>
"未填写包裹尺寸:计费重按实重估算,入库实测尺寸后价格可能变化。",
PriceCheckMissingFieldCodes.PostalCode =>
"未填写邮编:未校验黑名单/偏远/精确分区,报价仅供参考。",
"未填写邮编:优先按州/市或国家级分区匹配,精确邮编可能调整价格。",
PriceCheckMissingFieldCodes.Province =>
"未填写省州:分区按国家级匹配,精确地址可能调整价格。",
"未填写省州:无邮编时无法按州/市降级匹配,分区可能按国家级兜底。",
PriceCheckMissingFieldCodes.City =>
"未填写城市:无邮编且州未命中时无法按城市匹配。",
PriceCheckMissingFieldCodes.CargoType =>
"未选择货物属性:已分别试算普货/带电/敏感货等,下单时以所选属性为准。",
PriceCheckMissingFieldCodes.Warehouse =>

View File

@ -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";

View 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);
}

View File

@ -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)

View File

@ -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);
}

View File

@ -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>

View File

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

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

View File

@ -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();
}
}

View File

@ -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");
}
}