diff --git a/sql/migrate_kpi_area_publish_record.sql b/sql/migrate_kpi_area_publish_record.sql
new file mode 100644
index 0000000..aa8e3c2
--- /dev/null
+++ b/sql/migrate_kpi_area_publish_record.sql
@@ -0,0 +1,56 @@
+-- 分区包发布表(价区 / 黑名单 / 偏远):卖价/规则发布钉包,计费按 version 加载
+SET @db := DATABASE();
+
+SET @tbl := (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_kpi_area_publish_record'
+);
+SET @sql := IF(
+ @tbl = 0,
+ 'CREATE TABLE `t_kpi_area_publish_record` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+ `AreaId` INT NOT NULL COMMENT ''所属分区表 t_kpi_area.Id'',
+ `AreaType` VARCHAR(32) NOT NULL COMMENT ''分区类型:price=价格分区,blacklist=黑名单,remote=偏远'',
+ `Version` VARCHAR(32) NOT NULL COMMENT ''包版本号(yyyyMMddHHmmssfff)'',
+ `Status` TINYINT NOT NULL DEFAULT 1 COMMENT ''状态:1=生效 Active,2=失效 Inactive'',
+ `ContentHash` VARCHAR(64) NOT NULL COMMENT ''规范化分区明细 Content 的 SHA256(hex),同 AreaId+Hash 去重复用'',
+ `Content` JSON NULL COMMENT ''分区明细快照 JSON(KpiAreaSnapshotRuleDto 数组)'',
+ `PublishUser` VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''发布人'',
+ `PublishTime` DATETIME NOT NULL COMMENT ''发布时间'',
+ `Description` VARCHAR(512) NULL COMMENT ''发布说明'',
+ `IsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''软删除:0=否,1=是'',
+ `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_kpi_pack_area_hash` (`AreaId`, `ContentHash`),
+ KEY `idx_kpi_pack_area_ver` (`AreaId`, `Version`),
+ KEY `idx_kpi_pack_area_type_time` (`AreaId`, `AreaType`, `PublishTime`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''KPI分区发布包(价区/黑名单/偏远明细不可变快照)''',
+ 'SELECT ''t_kpi_area_publish_record already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 表已存在时回填表/字段注释(可重复执行;不包进 PREPARE)
+ALTER TABLE `t_kpi_area_publish_record`
+ COMMENT = 'KPI分区发布包(价区/黑名单/偏远明细不可变快照)',
+ MODIFY COLUMN `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
+ MODIFY COLUMN `AreaId` INT NOT NULL COMMENT '所属分区表 t_kpi_area.Id',
+ MODIFY COLUMN `AreaType` VARCHAR(32) NOT NULL COMMENT '分区类型:price=价格分区,blacklist=黑名单,remote=偏远',
+ MODIFY COLUMN `Version` VARCHAR(32) NOT NULL COMMENT '包版本号(yyyyMMddHHmmssfff)',
+ MODIFY COLUMN `Status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=生效 Active,2=失效 Inactive',
+ MODIFY COLUMN `ContentHash` VARCHAR(64) NOT NULL COMMENT '规范化分区明细 Content 的 SHA256(hex),同 AreaId+Hash 去重复用',
+ MODIFY COLUMN `Content` JSON NULL COMMENT '分区明细快照 JSON(KpiAreaSnapshotRuleDto 数组)',
+ MODIFY COLUMN `PublishUser` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '发布人',
+ MODIFY COLUMN `PublishTime` DATETIME NOT NULL COMMENT '发布时间',
+ MODIFY COLUMN `Description` VARCHAR(512) NULL COMMENT '发布说明',
+ MODIFY COLUMN `IsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除:0=否,1=是',
+ MODIFY COLUMN `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
+ MODIFY COLUMN `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ MODIFY COLUMN `UpdateUserId` INT NULL COMMENT '更新人',
+ MODIFY COLUMN `UpdateTime` DATETIME NULL COMMENT '更新时间';
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaPublishRecordOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaPublishRecordOutput.cs
new file mode 100644
index 0000000..bf41312
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaPublishRecordOutput.cs
@@ -0,0 +1,28 @@
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// KPI 分区包版本头/详情。
+///
+public class KpiAreaPublishRecordOutput
+{
+ public int Id { get; set; }
+
+ public int AreaId { get; set; }
+
+ public string AreaType { get; set; } = string.Empty;
+
+ public string Version { get; set; } = string.Empty;
+
+ public string Status { get; set; } = string.Empty;
+
+ public string ContentHash { get; set; } = string.Empty;
+
+ public string PublishUser { get; set; } = string.Empty;
+
+ public DateTime PublishTime { get; set; }
+
+ public string? Description { get; set; }
+
+ /// 仅 get 接口返回正文。
+ public object? Content { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Channels/SalesProductPublishSyncStatusOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductPublishSyncStatusOutput.cs
new file mode 100644
index 0000000..ae388e3
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/SalesProductPublishSyncStatusOutput.cs
@@ -0,0 +1,22 @@
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 销售产品发布同步态(轻量,不含快照正文)。
+///
+public class SalesProductPublishSyncStatusOutput
+{
+ /// 相对最新生效版本是否有待发布变更。
+ public bool Pending { get; set; }
+
+ /// 最新生效版本号;无则 null。
+ public string? OnlineVersion { get; set; }
+
+ /// 最新生效发布时间。
+ public DateTime? OnlinePublishTime { get; set; }
+
+ /// 待发布快照内容哈希(规范化后)。
+ public string? PendingContentHash { get; set; }
+
+ /// 线上快照内容哈希。
+ public string? OnlineContentHash { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Orders/KpiAreaPackRefDto.cs b/src/Oms.Application/Dtos/Orders/KpiAreaPackRefDto.cs
new file mode 100644
index 0000000..788a1f6
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/KpiAreaPackRefDto.cs
@@ -0,0 +1,17 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 卖价/规则快照内对分区包的引用(明细在 t_kpi_area_publish_record)。
+///
+public class KpiAreaPackRefDto
+{
+ public int AreaId { get; set; }
+
+ public string? AreaName { get; set; }
+
+ /// 分区包 Version(yyyyMMddHHmmss)。
+ public string PackVersion { get; set; } = string.Empty;
+
+ /// 包 ContentHash,便于同步态对比。
+ public string? ContentHash { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
index d3c698b..1140307 100644
--- a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs
@@ -1,9 +1,10 @@
+using System.Text.Json.Serialization;
using Oms.Application.Dtos.Channels;
namespace Oms.Application.Dtos.Orders;
///
-/// 销售产品价格发布快照(与渠道发布 JSON 同构)。
+/// 销售产品价格发布快照。
///
public class SalesProductPricePublishSnapshotDto
{
@@ -13,7 +14,7 @@ public class SalesProductPricePublishSnapshotDto
public int SalesProductId { get; set; }
///
- /// 价格分区 ID(发布时固化,查价/计费优先用快照内分区规则)。
+ /// 价格分区 ID(单表时冗余展示;以 PriceAreaRefs 为准)。
///
public int? PriceAreaId { get; set; }
@@ -23,8 +24,14 @@ public class SalesProductPricePublishSnapshotDto
public string? PriceAreaName { get; set; }
///
- /// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为闭区间。
+ /// 价格分区包引用(发布时钉包;明细在 t_kpi_area_publish_record)。
///
+ public List PriceAreaRefs { get; set; } = [];
+
+ ///
+ /// 运行时 hydrate / LIVE 用分区明细,不落发布 JSON。
+ ///
+ [JsonIgnore]
public List PriceAreaInfos { get; set; } = [];
///
@@ -34,7 +41,7 @@ public class SalesProductPricePublishSnapshotDto
}
///
-/// 销售产品规则发布快照(与渠道发布 JSON 同构)。
+/// 销售产品规则发布快照。
///
public class SalesProductRulesPublishSnapshotDto
{
@@ -49,7 +56,7 @@ public class SalesProductRulesPublishSnapshotDto
public List Providers { get; set; } = [];
///
- /// 黑名单分区 ID(发布时固化)。
+ /// 黑名单分区 ID。
///
public int? BlacklistAreaId { get; set; }
@@ -59,12 +66,18 @@ public class SalesProductRulesPublishSnapshotDto
public string? BlacklistAreaName { get; set; }
///
- /// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。
+ /// 黑名单分区包引用。
///
+ public KpiAreaPackRefDto? BlacklistAreaRef { get; set; }
+
+ ///
+ /// 运行时 hydrate / LIVE 用,不落发布 JSON。
+ ///
+ [JsonIgnore]
public List BlacklistAreaInfos { get; set; } = [];
///
- /// 偏远分区 ID(发布时固化)。
+ /// 偏远分区 ID。
///
public int? RemoteAreaId { get; set; }
@@ -74,8 +87,14 @@ public class SalesProductRulesPublishSnapshotDto
public string? RemoteAreaName { get; set; }
///
- /// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。
+ /// 偏远分区包引用。
///
+ public KpiAreaPackRefDto? RemoteAreaRef { get; set; }
+
+ ///
+ /// 运行时 hydrate / LIVE 用,不落发布 JSON。
+ ///
+ [JsonIgnore]
public List RemoteAreaInfos { get; set; } = [];
///
diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs
index 4a2d60f..ff902a5 100644
--- a/src/Oms.Application/Services/Channels/KpiAreaService.cs
+++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs
@@ -7,6 +7,7 @@ using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
+using Oms.Domain.Shared;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.Channels;
@@ -390,4 +391,63 @@ public class KpiAreaService : ApplicationService
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
}
+
+ ///
+ /// 分区包版本列表(不含正文)。
+ ///
+ [HttpGet("/channel/kpi-area/publish/list")]
+ public async Task> GetPublishListAsync([FromQuery] int areaId)
+ {
+ if (areaId <= 0)
+ {
+ throw new Fake.Application.BusinessException("分区表 Id 无效");
+ }
+
+ await GetAreaOrThrowAsync(areaId);
+ var headers = await KpiAreaRepository.GetPublishHeadersByAreaIdAsync(areaId);
+ return headers.Select(MapPublishHeader).ToList();
+ }
+
+ ///
+ /// 单条分区包(含正文)。
+ ///
+ [HttpGet("/channel/kpi-area/publish/get")]
+ public async Task GetPublishRecordAsync([FromQuery] int id)
+ {
+ var entity = await KpiAreaRepository.GetPublishByIdAsync(id)
+ ?? throw new Fake.Application.BusinessException("分区包不存在");
+ return MapPublishDetail(entity);
+ }
+
+ private async Task GetAreaOrThrowAsync(int areaId)
+ {
+ var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId);
+ if (area == null)
+ {
+ throw new Fake.Application.BusinessException("分区表不存在");
+ }
+
+ return area;
+ }
+
+ private static KpiAreaPublishRecordOutput MapPublishHeader(KpiAreaPublishRecord entity) =>
+ new()
+ {
+ Id = entity.Id,
+ AreaId = entity.AreaId,
+ AreaType = entity.AreaType,
+ Version = entity.Version,
+ Status = EnumDisplayNames.Of(entity.Status),
+ ContentHash = entity.ContentHash,
+ PublishUser = entity.PublishUser,
+ PublishTime = entity.PublishTime,
+ Description = entity.Description
+ };
+
+ private static KpiAreaPublishRecordOutput MapPublishDetail(KpiAreaPublishRecord entity)
+ {
+ var output = MapPublishHeader(entity);
+ output.Content = OmsDocHelper.DeserializeJsonObject(entity.Content);
+ return output;
+ }
}
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
index ed2bb5a..b126256 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
@@ -38,8 +38,28 @@ public partial class SalesProductService
}
var warnings = new List();
- var priceSnapshot = await BuildPricePublishSnapshotAsync(product.Id);
- var rulesSnapshot = await BuildRulesPublishSnapshotAsync(product.Id);
+ // 模拟用当前草稿:LIVE Infos,不强制走已发布包
+ var priceSnapshot = new SalesProductPricePublishSnapshotDto
+ {
+ SalesProductId = product.Id,
+ Prices = (await GetPriceOutputsAsync(product.Id)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
+ };
+ await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync(
+ priceSnapshot,
+ product,
+ KpiAreaRepository,
+ KpiAreaPackService);
+ var rulesSnapshot = new SalesProductRulesPublishSnapshotDto
+ {
+ Product = MapProductToRulesSaveInput(product)
+ };
+ await RulesSnapshotHelper.ApplyLiveRulesToSnapshotAsync(
+ rulesSnapshot,
+ product,
+ KpiAreaRepository,
+ WeightRuleRepository,
+ WeightRoundingRepository,
+ KpiAreaPackService);
var weight = new OrderWeight
{
@@ -261,7 +281,7 @@ public partial class SalesProductService
return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
}
- private async Task IsSimulateRemoteHitAsync(
+ private Task IsSimulateRemoteHitAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto rulesSnapshot,
string countryCode,
@@ -269,24 +289,17 @@ public partial class SalesProductService
string? city,
string? zipCode)
{
- var areaId = product.RemoteAreaId is > 0 ? product.RemoteAreaId : rulesSnapshot.RemoteAreaId;
- if (areaId is not > 0)
- {
- return false;
- }
-
if (rulesSnapshot.RemoteAreaInfos is { Count: > 0 })
{
- return GeoPartitionMatcher.IsHit(
+ return Task.FromResult(GeoPartitionMatcher.IsHit(
rulesSnapshot.RemoteAreaInfos,
countryCode,
province,
city,
- zipCode);
+ zipCode));
}
- var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
+ return Task.FromResult(false);
}
private async Task ResolveSimulateWarehouseNameAsync(int? warehouseId)
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
index ff29681..5ae3a5e 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
@@ -290,13 +290,15 @@ public partial class SalesProductService
product,
KpiAreaRepository,
WeightRuleRepository,
- WeightRoundingRepository);
+ WeightRoundingRepository,
+ KpiAreaPackService,
+ ResolvePublishUser());
await AllocationProviderSnapshotEnricher.EnrichAsync(rulesSnapshot.Providers);
return rulesSnapshot;
}
///
- /// 组装价格发布快照(不落库)。
+ /// 组装价格发布快照(不落库);价区钉分区包 Refs。
///
private async Task BuildPricePublishSnapshotAsync(int salesProductId)
{
@@ -306,10 +308,20 @@ public partial class SalesProductService
SalesProductId = salesProductId,
Prices = (await GetPriceOutputsAsync(salesProductId)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
};
- await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(priceSnapshot, product, KpiAreaRepository);
+ await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(
+ priceSnapshot,
+ product,
+ KpiAreaRepository,
+ KpiAreaPackService,
+ ResolvePublishUser());
return priceSnapshot;
}
+ private string ResolvePublishUser() =>
+ string.IsNullOrWhiteSpace(CurrentUser.UserName)
+ ? CurrentUser.GetId().ToString()
+ : CurrentUser.UserName!;
+
private static SalesProductSaveInput MapProductToRulesSaveInput(SalesProduct product) =>
new()
{
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs
index e8e7fd1..35f2f94 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.cs
@@ -23,6 +23,7 @@ public partial class SalesProductService : ApplicationService
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService();
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService();
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService();
+ private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
@@ -408,6 +409,67 @@ public partial class SalesProductService : ApplicationService
return OmsDocHelper.ToSnapshotApiContent(snapshot)!;
}
+ ///
+ /// 发布同步态(轻量):对比待发布与最新生效快照,不回传正文。
+ ///
+ [HttpGet("/channel/sales-product/publish/sync-status")]
+ public async Task GetPublishSyncStatusAsync(
+ [FromQuery] int salesProductId,
+ [FromQuery] string operatorType)
+ {
+ var normalized = NormalizeOperatorType(operatorType);
+ await GetProductOrThrowAsync(salesProductId);
+
+ object pendingSnapshot = normalized == PriceOperatorType
+ ? await BuildPricePublishSnapshotAsync(salesProductId)
+ : await BuildRulesPublishSnapshotAsync(salesProductId);
+ var pendingJson = OmsDocHelper.SerializeSnapshotJson(pendingSnapshot) ?? string.Empty;
+ var pendingHash = ComputeContentHash(pendingJson);
+
+ var headers = await SalesProductRepository.GetAllPublishRecordHeadersAsync(salesProductId, normalized);
+ var onlineHeader = headers
+ .Where(x => x.Status == Domain.Shared.Enums.EsSnapshotStatus.Active)
+ .OrderByDescending(x => x.PublishTime)
+ .FirstOrDefault();
+
+ if (onlineHeader == null)
+ {
+ return new SalesProductPublishSyncStatusOutput
+ {
+ Pending = true,
+ OnlineVersion = null,
+ OnlinePublishTime = null,
+ PendingContentHash = pendingHash,
+ OnlineContentHash = null
+ };
+ }
+
+ var onlineRecord = await SalesProductRepository.GetPublishRecordByIdAsync(onlineHeader.Id);
+ var onlineJson = onlineRecord?.Content ?? string.Empty;
+ // 线上 Content 与待发布统一经反序列化再序列化,避免字段顺序噪声
+ object? onlineNormalized = normalized == PriceOperatorType
+ ? OmsDocHelper.DeserializePriceSnapshot(onlineJson)
+ : OmsDocHelper.DeserializeRulesSnapshot(onlineJson);
+ var onlineCanon = OmsDocHelper.SerializeSnapshotJson(onlineNormalized) ?? string.Empty;
+ var onlineHash = ComputeContentHash(onlineCanon);
+
+ return new SalesProductPublishSyncStatusOutput
+ {
+ Pending = !string.Equals(pendingHash, onlineHash, StringComparison.Ordinal),
+ OnlineVersion = onlineHeader.Version,
+ OnlinePublishTime = onlineHeader.PublishTime,
+ PendingContentHash = pendingHash,
+ OnlineContentHash = onlineHash
+ };
+ }
+
+ private static string ComputeContentHash(string content)
+ {
+ var hash = global::System.Security.Cryptography.SHA256.HashData(
+ global::System.Text.Encoding.UTF8.GetBytes(content));
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+
///
/// 执行销售产品发布。
///
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 3423204..659aed6 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -51,6 +51,7 @@ public partial class OrderService
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService();
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService();
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService();
+ private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService();
@@ -552,7 +553,7 @@ public partial class OrderService
throw new BusinessException("未命中规则快照版本(内存/Redis/DB)");
}
- return new ProductSnapshotBinding
+ var binding = new ProductSnapshotBinding
{
RulesVersion = rulesRecord?.Version ?? $"LIVE-{product.Id}",
PriceVersion = priceRecord?.Version ?? $"LIVE-{product.Id}",
@@ -561,6 +562,8 @@ public partial class OrderService
Rules = rulesSnapshot,
Price = priceSnapshot
};
+ await KpiAreaPackService.HydrateBindingAsync(binding);
+ return binding;
}
private async Task ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order)
@@ -632,7 +635,7 @@ public partial class OrderService
priceEffectiveTime = priceRecord.PublishTime;
}
- return new ProductSnapshotBinding
+ var binding = new ProductSnapshotBinding
{
RulesVersion = rulesVersion,
PriceVersion = priceVersion,
@@ -641,6 +644,8 @@ public partial class OrderService
Rules = rulesSnapshot,
Price = priceSnapshot
};
+ await KpiAreaPackService.HydrateBindingAsync(binding);
+ return binding;
}
private async Task ResolveWarehouseNameAsync(int? warehouseId)
@@ -1412,12 +1417,13 @@ public partial class OrderService
Scheme = product.Scheme
}
};
- await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
+ await RulesSnapshotHelper.ApplyLiveRulesToSnapshotAsync(
snapshot,
product,
KpiAreaRepository,
WeightRuleRepository,
- WeightRoundingRepository);
+ WeightRoundingRepository,
+ KpiAreaPackService);
snapshot.Providers = await ResolveAllocationProvidersAsync(product, snapshot);
return snapshot;
}
@@ -1455,7 +1461,11 @@ public partial class OrderService
: []
})).ToList()
};
- await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, KpiAreaRepository);
+ await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync(
+ snapshot,
+ product,
+ KpiAreaRepository,
+ KpiAreaPackService);
return snapshot;
}
@@ -1484,7 +1494,7 @@ public partial class OrderService
};
}
- private async Task IsBlacklistHitAsync(
+ private Task IsBlacklistHitAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
string countryCode,
@@ -1492,27 +1502,26 @@ public partial class OrderService
string? city,
string? zipCode)
{
- var areaId = ResolveEffectiveBlacklistAreaId(product, rulesSnapshot);
- if (!areaId.HasValue)
+ if (rulesSnapshot?.BlacklistAreaRef != null && rulesSnapshot.BlacklistAreaInfos is not { Count: > 0 })
{
- return false;
+ throw new BusinessException(
+ $"黑名单分区包未加载: areaId={rulesSnapshot.BlacklistAreaRef.AreaId} version={rulesSnapshot.BlacklistAreaRef.PackVersion}");
}
if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 })
{
- return GeoPartitionMatcher.IsHit(
+ return Task.FromResult(GeoPartitionMatcher.IsHit(
rulesSnapshot.BlacklistAreaInfos,
countryCode,
province,
city,
- zipCode);
+ zipCode));
}
- var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
+ return Task.FromResult(false);
}
- private async Task IsRemoteHitAsync(
+ private Task IsRemoteHitAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
string countryCode,
@@ -1520,24 +1529,23 @@ public partial class OrderService
string? city,
string? zipCode)
{
- var areaId = ResolveEffectiveRemoteAreaId(product, rulesSnapshot);
- if (!areaId.HasValue)
+ if (rulesSnapshot?.RemoteAreaRef != null && rulesSnapshot.RemoteAreaInfos is not { Count: > 0 })
{
- return false;
+ throw new BusinessException(
+ $"偏远分区包未加载: areaId={rulesSnapshot.RemoteAreaRef.AreaId} version={rulesSnapshot.RemoteAreaRef.PackVersion}");
}
if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 })
{
- return GeoPartitionMatcher.IsHit(
+ return Task.FromResult(GeoPartitionMatcher.IsHit(
rulesSnapshot.RemoteAreaInfos,
countryCode,
province,
city,
- zipCode);
+ zipCode));
}
- var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
- return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
+ return Task.FromResult(false);
}
private static int? ResolveEffectiveBlacklistAreaId(
@@ -1607,7 +1615,7 @@ public partial class OrderService
return null;
}
- private async Task?> ResolvePriceZoneKeysAsync(
+ private Task?> ResolvePriceZoneKeysAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
SalesProductPricePublishSnapshotDto? priceSnapshot,
@@ -1616,42 +1624,24 @@ public partial class OrderService
string? city,
string? zipCode)
{
+ if (priceSnapshot?.PriceAreaRefs is { Count: > 0 }
+ && priceSnapshot.PriceAreaInfos is not { Count: > 0 })
+ {
+ throw new BusinessException("价格分区包未加载,无法匹配分区");
+ }
+
if (priceSnapshot?.PriceAreaInfos is { Count: > 0 })
{
- return ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
+ return Task.FromResult(ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
priceSnapshot,
countryCode,
province,
city,
- zipCode);
+ zipCode));
}
- var priceAreaId = ResolveEffectivePriceAreaId(product, rulesSnapshot, priceSnapshot);
- if (!priceAreaId.HasValue)
- {
- return null;
- }
-
- var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value);
- if (area == null)
- {
- return null;
- }
-
- var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value);
- var matched = GeoPartitionMatcher.MatchEntity(
- rules,
- countryCode,
- province,
- city,
- zipCode);
- if (matched == null)
- {
- return null;
- }
-
- var keys = ParcelBillingCalculator.BuildPriceZoneKeys(matched, area);
- return keys.Count > 0 ? keys : null;
+ // 无分区配置:返回 null;禁止回读线上 KPI(正式路径须走包)
+ return Task.FromResult?>(null);
}
private async Task ApplyChargeableWeightAsync(
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
index 4000bae..7ef6839 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
@@ -737,7 +737,13 @@ public partial class OrderService
}
// 仅国家+重量:分区规则带邮编时无法精确命中,按「同国家」分区给出估算价;
- // 快照中完全没有该国家时不跨国家兜底。
+ // 须已 hydrate;禁止回读线上 KPI。
+ if (snapshotBinding.Price?.PriceAreaRefs is { Count: > 0 }
+ && snapshotBinding.Price.PriceAreaInfos is not { Count: > 0 })
+ {
+ throw new BusinessException("价格分区包未加载,无法估算分区");
+ }
+
if (snapshotBinding.Price?.PriceAreaInfos is { Count: > 0 })
{
foreach (var zoneKey in ParcelBillingCalculator.CollectCountryEstimateZoneKeys(
@@ -746,42 +752,6 @@ public partial class OrderService
{
attempts.Add(new QuoteZoneAttempt([zoneKey], true, zoneKey));
}
-
- return attempts;
- }
-
- // 无快照分区明细时,退回库表:同国家且不要求邮编的规则
- var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price);
- if (priceAreaId is > 0)
- {
- var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value);
- var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value);
- foreach (var rule in rules)
- {
- if (!string.IsNullOrWhiteSpace(rule.CountryCode)
- && !string.Equals(rule.CountryCode, context.CountryCode, StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- if (!string.IsNullOrWhiteSpace(rule.Province) || !string.IsNullOrWhiteSpace(rule.City))
- {
- continue;
- }
-
- var keys = ParcelBillingCalculator.BuildPriceZoneKeys(rule, area);
- if (keys.Count == 0)
- {
- continue;
- }
-
- if (attempts.Any(x => x.ZoneKeys.SequenceEqual(keys, StringComparer.OrdinalIgnoreCase)))
- {
- continue;
- }
-
- attempts.Add(new QuoteZoneAttempt(keys.ToList(), true, keys[0]));
- }
}
return attempts;
diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
index 7cb00fd..19eef43 100644
--- a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
+++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
@@ -25,6 +25,7 @@ public class PickupOrderFeePushService : IScopedDependency
private readonly IWeightRoundingRepository _weightRoundingRepository;
private readonly IKpiAreaRepository _kpiAreaRepository;
private readonly IWarehouseAddressRepository _warehouseAddressRepository;
+ private readonly KpiAreaPackService _kpiAreaPackService;
public PickupOrderFeePushService(
IPickupOrderRepository pickupOrderRepository,
@@ -33,7 +34,8 @@ public class PickupOrderFeePushService : IScopedDependency
IWeightRuleRepository weightRuleRepository,
IWeightRoundingRepository weightRoundingRepository,
IKpiAreaRepository kpiAreaRepository,
- IWarehouseAddressRepository warehouseAddressRepository)
+ IWarehouseAddressRepository warehouseAddressRepository,
+ KpiAreaPackService kpiAreaPackService)
{
_pickupOrderRepository = pickupOrderRepository;
_salesProductRepository = salesProductRepository;
@@ -42,6 +44,7 @@ public class PickupOrderFeePushService : IScopedDependency
_weightRoundingRepository = weightRoundingRepository;
_kpiAreaRepository = kpiAreaRepository;
_warehouseAddressRepository = warehouseAddressRepository;
+ _kpiAreaPackService = kpiAreaPackService;
}
public async Task PushFeeAsync(PickupOpenApiPushFeeInput? input)
@@ -245,7 +248,11 @@ public class PickupOrderFeePushService : IScopedDependency
SalesProductId = product.Id,
Prices = (await GetSellPriceOutputsAsync(product.Id)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
};
- await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, _kpiAreaRepository);
+ await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync(
+ snapshot,
+ product,
+ _kpiAreaRepository,
+ _kpiAreaPackService);
return snapshot;
}
diff --git a/src/Oms.Application/Shared/KpiAreaPackService.cs b/src/Oms.Application/Shared/KpiAreaPackService.cs
new file mode 100644
index 0000000..4eb0ac3
--- /dev/null
+++ b/src/Oms.Application/Shared/KpiAreaPackService.cs
@@ -0,0 +1,317 @@
+using System.Security.Cryptography;
+using System.Text;
+using Fake.Application;
+using Fake.DependencyInjection;
+using Microsoft.Extensions.Caching.Memory;
+using Oms.Application.Dtos.Orders;
+using Oms.Domain.ChannelsAggregate.Entities;
+using Oms.Domain.ChannelsAggregate.Repositories;
+using Oms.Domain.Shared.Enums;
+
+namespace Oms.Application.Shared;
+
+///
+/// KPI 分区包:发布时 Ensure(ContentHash 去重),计费按 version 加载(缓存),缺失严格失败。
+///
+public class KpiAreaPackService : IScopedDependency
+{
+ public const string AreaTypePrice = "price";
+ public const string AreaTypeBlacklist = "blacklist";
+ public const string AreaTypeRemote = "remote";
+
+ private static readonly MemoryCache PackCache = new(new MemoryCacheOptions
+ {
+ SizeLimit = 256
+ });
+
+ private readonly IKpiAreaRepository _kpiAreaRepository;
+
+ public KpiAreaPackService(IKpiAreaRepository kpiAreaRepository)
+ {
+ _kpiAreaRepository = kpiAreaRepository;
+ }
+
+ ///
+ /// 按当前库明细组包;同 AreaId+ContentHash 复用已有 Version。
+ ///
+ public async Task EnsurePackAsync(
+ int areaId,
+ string areaType,
+ string? publishUser = null,
+ string? description = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (areaId <= 0)
+ {
+ throw new BusinessException("分区表 Id 无效");
+ }
+
+ var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId, cancellationToken)
+ ?? throw new BusinessException($"分区表不存在: {areaId}");
+
+ var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId, cancellationToken);
+ var rules = infos.Select(info => KpiAreaSnapshotMapper.MapToSnapshotRule(info, area.Name)).ToList();
+ PublishSnapshotNormalizer.NormalizeKpiAreas(rules);
+ rules = rules.OrderBy(x => x.Id).ToList();
+
+ var contentJson = OmsDocHelper.SerializeSnapshotJson(rules) ?? "[]";
+ var contentHash = ComputeSha256Hex(contentJson);
+
+ var existing = await _kpiAreaRepository.FindPublishByAreaIdAndContentHashAsync(
+ areaId,
+ contentHash,
+ cancellationToken);
+ if (existing != null)
+ {
+ return ToRef(existing, area.Name);
+ }
+
+ var version = DateTime.Now.ToString("yyyyMMddHHmmssfff");
+ var record = new KpiAreaPublishRecord
+ {
+ AreaId = areaId,
+ AreaType = string.IsNullOrWhiteSpace(areaType) ? area.AreaType : areaType.Trim(),
+ Version = version,
+ Status = EsSnapshotStatus.Active,
+ ContentHash = contentHash,
+ Content = contentJson,
+ PublishUser = string.IsNullOrWhiteSpace(publishUser) ? "system" : publishUser.Trim(),
+ PublishTime = DateTime.Now,
+ Description = description
+ };
+ record = await _kpiAreaRepository.InsertPublishRecordAsync(record, cancellationToken);
+ PackCache.Set(CacheKey(areaId, record.Version), rules, new MemoryCacheEntryOptions
+ {
+ Size = 1,
+ SlidingExpiration = TimeSpan.FromHours(6)
+ });
+ return ToRef(record, area.Name);
+ }
+
+ ///
+ /// 按引用加载分区规则;包不存在则严格失败。
+ ///
+ public async Task> GetRulesOrThrowAsync(
+ KpiAreaPackRefDto? packRef,
+ CancellationToken cancellationToken = default)
+ {
+ if (packRef == null || packRef.AreaId <= 0 || string.IsNullOrWhiteSpace(packRef.PackVersion))
+ {
+ throw new BusinessException("分区包引用无效");
+ }
+
+ return await GetRulesOrThrowAsync(packRef.AreaId, packRef.PackVersion, cancellationToken);
+ }
+
+ public async Task> GetRulesOrThrowAsync(
+ int areaId,
+ string packVersion,
+ CancellationToken cancellationToken = default)
+ {
+ var key = CacheKey(areaId, packVersion);
+ if (PackCache.TryGetValue(key, out List? cached) && cached != null)
+ {
+ return cached;
+ }
+
+ var record = await _kpiAreaRepository.GetPublishByAreaIdAndVersionAsync(
+ areaId,
+ packVersion,
+ cancellationToken);
+ if (record == null || string.IsNullOrWhiteSpace(record.Content))
+ {
+ throw new BusinessException($"分区包不存在: areaId={areaId} version={packVersion}");
+ }
+
+ var rules = OmsDocHelper.DeserializeJson>(record.Content) ?? [];
+ PublishSnapshotNormalizer.NormalizeKpiAreas(rules);
+ PackCache.Set(key, rules, new MemoryCacheEntryOptions
+ {
+ Size = 1,
+ SlidingExpiration = TimeSpan.FromHours(6)
+ });
+ return rules;
+ }
+
+ ///
+ /// 将卖价快照中的 PriceAreaRefs hydrate 到 PriceAreaInfos(运行时,不回写库)。
+ ///
+ public async Task HydratePriceSnapshotAsync(
+ SalesProductPricePublishSnapshotDto snapshot,
+ CancellationToken cancellationToken = default)
+ {
+ // LIVE/模拟:无 Refs,Infos 已由 LoadLive 填充,勿清空
+ if (snapshot.PriceAreaRefs is not { Count: > 0 })
+ {
+ return;
+ }
+
+ var merged = new List();
+ var seen = new HashSet();
+ foreach (var packRef in snapshot.PriceAreaRefs)
+ {
+ var rules = await GetRulesOrThrowAsync(packRef, cancellationToken);
+ foreach (var rule in rules)
+ {
+ if (seen.Add(rule.Id))
+ {
+ merged.Add(rule);
+ }
+ }
+ }
+
+ snapshot.PriceAreaInfos = merged;
+ if (snapshot.PriceAreaRefs.Count == 1)
+ {
+ snapshot.PriceAreaId = snapshot.PriceAreaRefs[0].AreaId;
+ snapshot.PriceAreaName = snapshot.PriceAreaRefs[0].AreaName;
+ }
+ else
+ {
+ snapshot.PriceAreaId = null;
+ snapshot.PriceAreaName = string.Join(
+ "、",
+ snapshot.PriceAreaRefs
+ .Select(x => x.AreaName)
+ .Where(x => !string.IsNullOrWhiteSpace(x)));
+ }
+ }
+
+ ///
+ /// 将规则快照中的黑名单/偏远 Ref hydrate 到 Infos。
+ ///
+ public async Task HydrateRulesSnapshotAsync(
+ SalesProductRulesPublishSnapshotDto snapshot,
+ CancellationToken cancellationToken = default)
+ {
+ if (snapshot.BlacklistAreaRef != null)
+ {
+ snapshot.BlacklistAreaInfos = await GetRulesOrThrowAsync(snapshot.BlacklistAreaRef, cancellationToken);
+ snapshot.BlacklistAreaId = snapshot.BlacklistAreaRef.AreaId;
+ snapshot.BlacklistAreaName = snapshot.BlacklistAreaRef.AreaName;
+ }
+ // else:LIVE 保留已有 Infos
+
+ if (snapshot.RemoteAreaRef != null)
+ {
+ snapshot.RemoteAreaInfos = await GetRulesOrThrowAsync(snapshot.RemoteAreaRef, cancellationToken);
+ snapshot.RemoteAreaId = snapshot.RemoteAreaRef.AreaId;
+ snapshot.RemoteAreaName = snapshot.RemoteAreaRef.AreaName;
+ }
+ }
+
+ public async Task HydrateBindingAsync(
+ ProductSnapshotBinding binding,
+ CancellationToken cancellationToken = default)
+ {
+ await HydratePriceSnapshotAsync(binding.Price, cancellationToken);
+ await HydrateRulesSnapshotAsync(binding.Rules, cancellationToken);
+ }
+
+ ///
+ /// 从当前库装载分区规则到 Infos(仅 LIVE/模拟;不写包、不写 Ref)。
+ ///
+ public async Task LoadLiveInfosIntoPriceSnapshotAsync(
+ SalesProductPricePublishSnapshotDto snapshot,
+ IReadOnlyList areaIds,
+ CancellationToken cancellationToken = default)
+ {
+ var merged = new List();
+ var seen = new HashSet();
+ var names = new List();
+ foreach (var areaId in areaIds.Where(id => id > 0).Distinct())
+ {
+ var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId, cancellationToken);
+ if (!string.IsNullOrWhiteSpace(area?.Name))
+ {
+ names.Add(area.Name.Trim());
+ }
+
+ var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId, cancellationToken);
+ foreach (var info in infos)
+ {
+ if (!seen.Add(info.Id))
+ {
+ continue;
+ }
+
+ merged.Add(KpiAreaSnapshotMapper.MapToSnapshotRule(info, area?.Name));
+ }
+ }
+
+ PublishSnapshotNormalizer.NormalizeKpiAreas(merged);
+ snapshot.PriceAreaInfos = merged;
+ snapshot.PriceAreaRefs = [];
+ if (areaIds.Count == 1)
+ {
+ snapshot.PriceAreaId = areaIds[0];
+ snapshot.PriceAreaName = names.Count > 0 ? names[0] : null;
+ }
+ else
+ {
+ snapshot.PriceAreaId = null;
+ snapshot.PriceAreaName = names.Count > 0 ? string.Join("、", names) : null;
+ }
+ }
+
+ public async Task LoadLiveInfosIntoRulesSnapshotAsync(
+ SalesProductRulesPublishSnapshotDto snapshot,
+ int? blacklistAreaId,
+ int? remoteAreaId,
+ CancellationToken cancellationToken = default)
+ {
+ snapshot.BlacklistAreaRef = null;
+ snapshot.RemoteAreaRef = null;
+ if (blacklistAreaId is > 0)
+ {
+ var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == blacklistAreaId.Value, cancellationToken);
+ var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(blacklistAreaId.Value, cancellationToken);
+ var rules = infos.Select(i => KpiAreaSnapshotMapper.MapToSnapshotRule(i, area?.Name)).ToList();
+ PublishSnapshotNormalizer.NormalizeKpiAreas(rules);
+ snapshot.BlacklistAreaId = blacklistAreaId;
+ snapshot.BlacklistAreaName = area?.Name;
+ snapshot.BlacklistAreaInfos = rules;
+ }
+ else
+ {
+ snapshot.BlacklistAreaId = null;
+ snapshot.BlacklistAreaName = null;
+ snapshot.BlacklistAreaInfos = [];
+ }
+
+ if (remoteAreaId is > 0)
+ {
+ var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == remoteAreaId.Value, cancellationToken);
+ var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(remoteAreaId.Value, cancellationToken);
+ var rules = infos.Select(i => KpiAreaSnapshotMapper.MapToSnapshotRule(i, area?.Name)).ToList();
+ PublishSnapshotNormalizer.NormalizeKpiAreas(rules);
+ snapshot.RemoteAreaId = remoteAreaId;
+ snapshot.RemoteAreaName = area?.Name;
+ snapshot.RemoteAreaInfos = rules;
+ }
+ else
+ {
+ snapshot.RemoteAreaId = null;
+ snapshot.RemoteAreaName = null;
+ snapshot.RemoteAreaInfos = [];
+ }
+ }
+
+ private static KpiAreaPackRefDto ToRef(KpiAreaPublishRecord record, string? areaName) =>
+ new()
+ {
+ AreaId = record.AreaId,
+ AreaName = areaName,
+ PackVersion = record.Version,
+ ContentHash = record.ContentHash
+ };
+
+ private static string CacheKey(int areaId, string version) =>
+ $"kpi-area-pack:{areaId}:{version}";
+
+ private static string ComputeSha256Hex(string content)
+ {
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+}
diff --git a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs
index 67d560e..fbcdcae 100644
--- a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs
+++ b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs
@@ -6,18 +6,19 @@ using Oms.Domain.ChannelsAggregate.Repositories;
namespace Oms.Application.Shared;
///
-/// 将价格分区(t_kpi_area + t_kpi_area_info)固化进卖价发布快照。
-/// 支持卖价明细引用多张价格分区表:合并各表明细规则写入快照。
+/// 卖价发布:解析价区表 Id 并钉分区包引用(不内嵌 Infos)。
///
public static class PriceAreaSnapshotHelper
{
///
- /// 按产品 PriceAreaId(或从卖价明细分区推断的全部 AreaId)加载分区头与明细写入快照。
+ /// 写入 PriceAreaRefs(Ensure 分区包);Infos 仅 LIVE 路径由 PackService.LoadLive 填充。
///
public static async Task ApplyPriceAreaToSnapshotAsync(
SalesProductPricePublishSnapshotDto snapshot,
SalesProduct product,
IKpiAreaRepository kpiAreaRepository,
+ KpiAreaPackService packService,
+ string? publishUser = null,
CancellationToken cancellationToken = default)
{
var areaIds = await CollectResolvedPriceAreaIdsAsync(
@@ -25,53 +26,59 @@ public static class PriceAreaSnapshotHelper
snapshot,
kpiAreaRepository,
cancellationToken);
+ snapshot.PriceAreaInfos = [];
if (areaIds.Count == 0)
{
snapshot.PriceAreaId = null;
snapshot.PriceAreaName = null;
- snapshot.PriceAreaInfos = [];
+ snapshot.PriceAreaRefs = [];
return;
}
- var mergedInfos = new List();
- var seenInfoIds = new HashSet();
- var areaNames = new List();
-
+ var refs = new List();
foreach (var areaId in areaIds)
{
- var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId, cancellationToken);
- var areaName = area?.Name;
- if (!string.IsNullOrWhiteSpace(areaName))
- {
- areaNames.Add(areaName.Trim());
- }
-
- var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId, cancellationToken);
- foreach (var info in infos)
- {
- if (!seenInfoIds.Add(info.Id))
- {
- continue;
- }
-
- mergedInfos.Add(KpiAreaSnapshotMapper.MapToSnapshotRule(info, areaName));
- }
+ refs.Add(await packService.EnsurePackAsync(
+ areaId,
+ KpiAreaPackService.AreaTypePrice,
+ publishUser,
+ description: "sales-product-price-publish",
+ cancellationToken));
}
- snapshot.PriceAreaInfos = mergedInfos;
- if (areaIds.Count == 1)
+ snapshot.PriceAreaRefs = refs;
+ if (refs.Count == 1)
{
- snapshot.PriceAreaId = areaIds[0];
- snapshot.PriceAreaName = areaNames.Count > 0 ? areaNames[0] : null;
+ snapshot.PriceAreaId = refs[0].AreaId;
+ snapshot.PriceAreaName = refs[0].AreaName;
}
else
{
- // 多表:不再落单一 PriceAreaId,名称拼接便于人工查看
snapshot.PriceAreaId = null;
- snapshot.PriceAreaName = areaNames.Count > 0 ? string.Join("、", areaNames) : null;
+ snapshot.PriceAreaName = string.Join(
+ "、",
+ refs.Select(x => x.AreaName).Where(x => !string.IsNullOrWhiteSpace(x)));
}
}
+ ///
+ /// LIVE/模拟:不钉包,直接从当前库装载 Infos。
+ ///
+ public static async Task ApplyLivePriceAreaInfosAsync(
+ SalesProductPricePublishSnapshotDto snapshot,
+ SalesProduct product,
+ IKpiAreaRepository kpiAreaRepository,
+ KpiAreaPackService packService,
+ CancellationToken cancellationToken = default)
+ {
+ var areaIds = await CollectResolvedPriceAreaIdsAsync(
+ product,
+ snapshot,
+ kpiAreaRepository,
+ cancellationToken);
+ await packService.LoadLiveInfosIntoPriceSnapshotAsync(snapshot, areaIds, cancellationToken);
+ }
+
///
/// 收集卖价明细(及产品 PriceAreaId)可解析到的全部价格分区表 Id,去重保序。
///
@@ -106,9 +113,6 @@ public static class PriceAreaSnapshotHelper
return result;
}
- ///
- /// 兼容旧调用:仅返回第一个可解析 AreaId(多表场景请用 CollectResolvedPriceAreaIdsAsync)。
- ///
internal static async Task ResolvePriceAreaIdAsync(
SalesProduct product,
SalesProductPricePublishSnapshotDto snapshot,
diff --git a/src/Oms.Application/Shared/RulesSnapshotHelper.cs b/src/Oms.Application/Shared/RulesSnapshotHelper.cs
index 9643354..5d64b2b 100644
--- a/src/Oms.Application/Shared/RulesSnapshotHelper.cs
+++ b/src/Oms.Application/Shared/RulesSnapshotHelper.cs
@@ -1,17 +1,16 @@
using Oms.Application.Dtos.Orders;
-using Oms.Application.Services.Orders;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
namespace Oms.Application.Shared;
///
-/// 将黑名单/偏远/计重/进位规则固化进销售产品规则发布快照。
+/// 规则发布:黑名单/偏远钉分区包;计重/进位仍内嵌明细。
///
public static class RulesSnapshotHelper
{
///
- /// 按产品配置加载 KPI 分区、计重规则、进位区间写入规则快照。
+ /// 发布路径:黑名单/偏远 Ensure 包写 Ref;计重/进位写 Infos。
///
public static async Task ApplyRulesToSnapshotAsync(
SalesProductRulesPublishSnapshotDto snapshot,
@@ -19,29 +18,37 @@ public static class RulesSnapshotHelper
IKpiAreaRepository kpiAreaRepository,
IWeightRuleRepository weightRuleRepository,
IWeightRoundingRepository weightRoundingRepository,
+ KpiAreaPackService packService,
+ string? publishUser = null,
CancellationToken cancellationToken = default)
{
- await ApplyKpiAreaToSnapshotAsync(
+ await ApplyKpiAreaPackRefAsync(
snapshot,
product.BlacklistAreaId,
- kpiAreaRepository,
- (s, areaId, areaName, infos) =>
+ KpiAreaPackService.AreaTypeBlacklist,
+ packService,
+ publishUser,
+ (s, areaId, areaName, packRef) =>
{
s.BlacklistAreaId = areaId;
s.BlacklistAreaName = areaName;
- s.BlacklistAreaInfos = infos;
+ s.BlacklistAreaRef = packRef;
+ s.BlacklistAreaInfos = [];
},
cancellationToken);
- await ApplyKpiAreaToSnapshotAsync(
+ await ApplyKpiAreaPackRefAsync(
snapshot,
product.RemoteAreaId,
- kpiAreaRepository,
- (s, areaId, areaName, infos) =>
+ KpiAreaPackService.AreaTypeRemote,
+ packService,
+ publishUser,
+ (s, areaId, areaName, packRef) =>
{
s.RemoteAreaId = areaId;
s.RemoteAreaName = areaName;
- s.RemoteAreaInfos = infos;
+ s.RemoteAreaRef = packRef;
+ s.RemoteAreaInfos = [];
},
cancellationToken);
@@ -49,6 +56,27 @@ public static class RulesSnapshotHelper
await ApplyWeightRoundingToSnapshotAsync(snapshot, product, weightRoundingRepository, cancellationToken);
}
+ ///
+ /// LIVE/模拟:黑名单/偏远从当前库装 Infos,不写 Ref。
+ ///
+ public static async Task ApplyLiveRulesToSnapshotAsync(
+ SalesProductRulesPublishSnapshotDto snapshot,
+ SalesProduct product,
+ IKpiAreaRepository kpiAreaRepository,
+ IWeightRuleRepository weightRuleRepository,
+ IWeightRoundingRepository weightRoundingRepository,
+ KpiAreaPackService packService,
+ CancellationToken cancellationToken = default)
+ {
+ await packService.LoadLiveInfosIntoRulesSnapshotAsync(
+ snapshot,
+ product.BlacklistAreaId,
+ product.RemoteAreaId,
+ cancellationToken);
+ await ApplyWeightRuleToSnapshotAsync(snapshot, product, weightRuleRepository, cancellationToken);
+ await ApplyWeightRoundingToSnapshotAsync(snapshot, product, weightRoundingRepository, cancellationToken);
+ }
+
///
/// 从快照内嵌黑名单/偏远明细匹配地址(不查库)。
///
@@ -67,38 +95,34 @@ public static class RulesSnapshotHelper
return GeoPartitionMatcher.IsHit(infos, countryCode, province, city, zipCode);
}
- ///
- /// 将快照内嵌计重明细转为实体供计算器使用。
- ///
public static List ToWeightRuleEntities(IReadOnlyList infos) =>
infos.Select(PublishSnapshotSlimmer.ToWeightRuleEntity).ToList();
- ///
- /// 将快照内嵌进位区间转为实体供计算器使用。
- ///
public static List ToWeightRoundingEntities(IReadOnlyList infos) =>
infos.Select(PublishSnapshotSlimmer.ToWeightRoundingEntity).ToList();
- private static async Task ApplyKpiAreaToSnapshotAsync(
+ private static async Task ApplyKpiAreaPackRefAsync(
SalesProductRulesPublishSnapshotDto snapshot,
int? areaId,
- IKpiAreaRepository kpiAreaRepository,
- Action> assign,
+ string areaType,
+ KpiAreaPackService packService,
+ string? publishUser,
+ Action assign,
CancellationToken cancellationToken)
{
if (!areaId.HasValue || areaId.Value <= 0)
{
- assign(snapshot, null, null, []);
+ assign(snapshot, null, null, null);
return;
}
- var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId.Value, cancellationToken);
- var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value, cancellationToken);
- assign(
- snapshot,
- areaId,
- area?.Name,
- infos.Select(info => KpiAreaSnapshotMapper.MapToSnapshotRule(info)).ToList());
+ var packRef = await packService.EnsurePackAsync(
+ areaId.Value,
+ areaType,
+ publishUser,
+ description: "sales-product-rules-publish",
+ cancellationToken);
+ assign(snapshot, areaId, packRef.AreaName, packRef);
}
private static async Task ApplyWeightRuleToSnapshotAsync(
diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaPublishRecord.cs b/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaPublishRecord.cs
new file mode 100644
index 0000000..7d03858
--- /dev/null
+++ b/src/Oms.Domain/ChannelsAggregate/Entities/KpiAreaPublishRecord.cs
@@ -0,0 +1,33 @@
+using Oms.Domain.Shared;
+using Oms.Domain.Shared.Enums;
+using SqlSugar;
+
+namespace Oms.Domain.ChannelsAggregate.Entities;
+
+///
+/// KPI 分区发布包(价区/黑名单/偏远明细不可变快照)。
+///
+[SugarTable("t_kpi_area_publish_record")]
+public class KpiAreaPublishRecord : OmsAuditedEntity
+{
+ public int AreaId { get; set; }
+
+ /// price / blacklist / remote
+ public string AreaType { get; set; } = string.Empty;
+
+ public string Version { get; set; } = string.Empty;
+
+ public EsSnapshotStatus Status { get; set; } = EsSnapshotStatus.Active;
+
+ public string ContentHash { get; set; } = string.Empty;
+
+ [SugarColumn(ColumnDataType = "json", IsNullable = true)]
+ public string? Content { get; set; }
+
+ public string PublishUser { get; set; } = string.Empty;
+
+ public DateTime PublishTime { get; set; }
+
+ [SugarColumn(IsNullable = true, Length = 512)]
+ public string? Description { get; set; }
+}
diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaPublishRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaPublishRepository.cs
new file mode 100644
index 0000000..14f0836
--- /dev/null
+++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaPublishRepository.cs
@@ -0,0 +1,31 @@
+using Oms.Domain.ChannelsAggregate.Entities;
+
+namespace Oms.Domain.ChannelsAggregate.Repositories;
+
+///
+/// KPI 分区发布包读写(可由 一并实现)。
+///
+public interface IKpiAreaPublishRepository
+{
+ Task FindPublishByAreaIdAndContentHashAsync(
+ int areaId,
+ string contentHash,
+ CancellationToken cancellationToken = default);
+
+ Task GetPublishByAreaIdAndVersionAsync(
+ int areaId,
+ string version,
+ CancellationToken cancellationToken = default);
+
+ Task> GetPublishHeadersByAreaIdAsync(
+ int areaId,
+ CancellationToken cancellationToken = default);
+
+ Task GetPublishByIdAsync(
+ int id,
+ CancellationToken cancellationToken = default);
+
+ Task InsertPublishRecordAsync(
+ KpiAreaPublishRecord record,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs
index 5b3c230..57db116 100644
--- a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs
+++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs
@@ -3,7 +3,7 @@ using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Domain.ChannelsAggregate.Repositories;
-public interface IKpiAreaRepository : IRepository
+public interface IKpiAreaRepository : IRepository, IKpiAreaPublishRepository
{
Task GetByAppKeyTypeAndNameAsync(string appKey, string areaType, string name, CancellationToken cancellationToken = default);
diff --git a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs
index 53ba77e..1099272 100644
--- a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs
+++ b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs
@@ -2,6 +2,7 @@ using Fake.DependencyInjection;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
+using SqlSugar;
namespace Oms.SqlSugarCore.Channels;
@@ -64,4 +65,81 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit
.OrderBy(x => x.Id)
.ToListAsync(cancellationToken);
}
+
+ public async Task FindPublishByAreaIdAndContentHashAsync(
+ int areaId,
+ string contentHash,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ var rows = await db.Queryable()
+ .Where(x => x.AreaId == areaId && x.ContentHash == contentHash)
+ .OrderBy(x => x.Id, OrderByType.Desc)
+ .Take(1)
+ .ToListAsync(cancellationToken);
+ return rows.FirstOrDefault();
+ }
+
+ public async Task GetPublishByAreaIdAndVersionAsync(
+ int areaId,
+ string version,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(version))
+ {
+ return null;
+ }
+
+ var db = await GetDbContextAsync(cancellationToken);
+ var rows = await db.Queryable()
+ .Where(x => x.AreaId == areaId && x.Version == version)
+ .OrderBy(x => x.Id, OrderByType.Desc)
+ .Take(1)
+ .ToListAsync(cancellationToken);
+ return rows.FirstOrDefault();
+ }
+
+ public async Task> GetPublishHeadersByAreaIdAsync(
+ int areaId,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await db.Queryable()
+ .Where(x => x.AreaId == areaId)
+ .OrderBy(x => x.PublishTime, OrderByType.Desc)
+ .Select(x => new KpiAreaPublishRecord
+ {
+ Id = x.Id,
+ AreaId = x.AreaId,
+ AreaType = x.AreaType,
+ Version = x.Version,
+ Status = x.Status,
+ ContentHash = x.ContentHash,
+ PublishUser = x.PublishUser,
+ PublishTime = x.PublishTime,
+ Description = x.Description,
+ CreateTime = x.CreateTime
+ })
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task GetPublishByIdAsync(
+ int id,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ var rows = await db.Queryable()
+ .Where(x => x.Id == id)
+ .Take(1)
+ .ToListAsync(cancellationToken);
+ return rows.FirstOrDefault();
+ }
+
+ public async Task InsertPublishRecordAsync(
+ KpiAreaPublishRecord record,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await db.Insertable(record).ExecuteReturnEntityAsync();
+ }
}
diff --git a/src/Oms.Tests/RulesSnapshotHelperPublishTests.cs b/src/Oms.Tests/RulesSnapshotHelperPublishTests.cs
index c27ad84..9b4a646 100644
--- a/src/Oms.Tests/RulesSnapshotHelperPublishTests.cs
+++ b/src/Oms.Tests/RulesSnapshotHelperPublishTests.cs
@@ -1,3 +1,4 @@
+using System.Linq.Expressions;
using FluentAssertions;
using Moq;
using Oms.Application.Dtos.Orders;
@@ -10,7 +11,7 @@ namespace Oms.Tests;
public class RulesSnapshotHelperPublishTests
{
[Fact]
- public async Task ApplyRulesToSnapshotAsync_ShouldEmbedBlacklistRemoteWeightAndRounding()
+ public async Task ApplyRulesToSnapshotAsync_ShouldPinBlacklistRemotePacksAndEmbedWeight()
{
var product = new SalesProduct
{
@@ -22,10 +23,16 @@ public class RulesSnapshotHelperPublishTests
WeightRoundingId = 2
};
+ var areas = new Dictionary
+ {
+ [10] = new() { Id = 10, Name = "黑名单区", AreaType = "blacklist" },
+ [20] = new() { Id = 20, Name = "偏远区", AreaType = "remote" }
+ };
+
var kpiMock = new Mock();
- kpiMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default))
- .ReturnsAsync((System.Linq.Expressions.Expression> _, CancellationToken _) =>
- new KpiArea { Id = 10, Name = "黑名单区" });
+ kpiMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default))
+ .ReturnsAsync((Expression> expr, CancellationToken _) =>
+ areas.Values.FirstOrDefault(expr.Compile()));
kpiMock.Setup(x => x.GetAllInfosByAreaIdAsync(10, default))
.ReturnsAsync(
[
@@ -36,9 +43,17 @@ public class RulesSnapshotHelperPublishTests
[
new KpiAreaInfo { Id = 2, AreaId = 20, CountryCode = "US", LeftPostcode = "06000", RightPostcode = "06999" }
]);
+ kpiMock.Setup(x => x.FindPublishByAreaIdAndContentHashAsync(It.IsAny(), It.IsAny(), default))
+ .ReturnsAsync((KpiAreaPublishRecord?)null);
+ kpiMock.Setup(x => x.InsertPublishRecordAsync(It.IsAny(), default))
+ .ReturnsAsync((KpiAreaPublishRecord r, CancellationToken _) =>
+ {
+ r.Id = r.AreaId * 100;
+ return r;
+ });
var weightRuleMock = new Mock();
- weightRuleMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default))
+ weightRuleMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default))
.ReturnsAsync(new WeightRule { Id = 1, Name = "标准计重" });
weightRuleMock.Setup(x => x.GetAllInfosByRuleIdAsync(1, default))
.ReturnsAsync(
@@ -54,7 +69,7 @@ public class RulesSnapshotHelperPublishTests
]);
var roundingMock = new Mock();
- roundingMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default))
+ roundingMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny>>(), default))
.ReturnsAsync(new WeightRounding { Id = 2, Name = "标准进位" });
roundingMock.Setup(x => x.GetAllInfosByRoundingIdAsync(2, default))
.ReturnsAsync(
@@ -62,31 +77,30 @@ public class RulesSnapshotHelperPublishTests
new WeightRoundingInfo { Id = 1, RoundingId = 2, StartValue = 0, EndValue = 20, Public = "0.5" }
]);
+ var packService = new KpiAreaPackService(kpiMock.Object);
var snapshot = new SalesProductRulesPublishSnapshotDto();
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
snapshot,
product,
kpiMock.Object,
weightRuleMock.Object,
- roundingMock.Object);
+ roundingMock.Object,
+ packService,
+ "tester");
snapshot.BlacklistAreaId.Should().Be(10);
- snapshot.BlacklistAreaInfos.Should().HaveCount(1);
- snapshot.RemoteAreaId.Should().Be(20);
- snapshot.RemoteAreaInfos.Should().HaveCount(1);
- snapshot.WeightRuleId.Should().Be(1);
+ snapshot.BlacklistAreaRef.Should().NotBeNull();
+ snapshot.BlacklistAreaRef!.PackVersion.Should().NotBeNullOrWhiteSpace();
+ snapshot.BlacklistAreaInfos.Should().BeEmpty();
+ snapshot.RemoteAreaRef.Should().NotBeNull();
+ snapshot.RemoteAreaRef!.AreaId.Should().Be(20);
snapshot.WeightRuleInfos.Should().HaveCount(1);
- snapshot.WeightRoundingId.Should().Be(2);
snapshot.WeightRoundingInfos.Should().HaveCount(1);
- var json = OmsDocHelper.SerializeSnapshotJson(snapshot);
- json.Should().Contain("PostcodeRange");
- json.Should().Contain("(90000,90010]");
- json.Should().Contain("WeightRuleInfos");
- json.Should().Contain("WeightRoundingInfos");
- json.Should().Contain("RemoteAreaInfos");
- json.Should().NotContain("CreateTime");
- json.Should().NotContain("UpdateTime");
- json.Should().NotContain("LeftPostcode");
+ var json = OmsDocHelper.SerializeSnapshotJson(snapshot)!;
+ json.Should().Contain("blacklistAreaRef");
+ json.Should().Contain("remoteAreaRef");
+ json.Should().NotContain("blacklistAreaInfos");
+ json.Should().NotContain("remoteAreaInfos");
}
}