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