diff --git a/src/Oms.Application/Dtos/Channels/ServiceChannelPublishSyncStatusOutput.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelPublishSyncStatusOutput.cs new file mode 100644 index 0000000..cd94690 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelPublishSyncStatusOutput.cs @@ -0,0 +1,22 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// 服务渠道发布同步态(轻量,不含快照正文)。 +/// +public class ServiceChannelPublishSyncStatusOutput +{ + /// 相对最新生效版本是否有待发布变更。 + 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/Channels/ServiceChannelRulesPublishSnapshotDto.cs b/src/Oms.Application/Dtos/Channels/ServiceChannelRulesPublishSnapshotDto.cs index 553f95f..e192c5e 100644 --- a/src/Oms.Application/Dtos/Channels/ServiceChannelRulesPublishSnapshotDto.cs +++ b/src/Oms.Application/Dtos/Channels/ServiceChannelRulesPublishSnapshotDto.cs @@ -1,3 +1,6 @@ +using System.Text.Json.Serialization; +using Oms.Application.Dtos.Orders; + namespace Oms.Application.Dtos.Channels; /// @@ -8,4 +11,17 @@ public class ServiceChannelRulesPublishSnapshotDto public ServiceChannelSaveInput? Channel { get; set; } public ServiceChannelDownstreamSnapshotDto? Downstream { get; set; } + + /// 偏远分区 ID。 + public int? RemoteAreaId { get; set; } + + /// 偏远分区名称。 + public string? RemoteAreaName { get; set; } + + /// 偏远分区包引用(规则发布时钉包)。 + public KpiAreaPackRefDto? RemoteAreaRef { get; set; } + + /// 运行时 hydrate / LIVE 用,不落发布 JSON。 + [JsonIgnore] + public List RemoteAreaInfos { get; set; } = []; } diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs index 1140307..0486a17 100644 --- a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs +++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs @@ -129,12 +129,25 @@ public class SalesProductRulesPublishSnapshotDto } /// -/// 服务渠道买价发布快照。 +/// 服务渠道买价发布快照(渠道自有价区,与产品卖价分区独立)。 /// public class ServiceChannelPricePublishSnapshotDto { public int ServiceChannelId { get; set; } + /// 价格分区 ID(单表时冗余展示;以 PriceAreaRefs 为准)。 + public int? PriceAreaId { get; set; } + + /// 价格分区名称。 + public string? PriceAreaName { get; set; } + + /// 渠道价区包引用(买价发布时钉包)。 + public List PriceAreaRefs { get; set; } = []; + + /// 运行时 hydrate / LIVE 用,不落发布 JSON。 + [JsonIgnore] + public List PriceAreaInfos { get; set; } = []; + public List Prices { get; set; } = []; } diff --git a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs index b126256..3c6d5f9 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs @@ -185,13 +185,16 @@ public partial class SalesProductService payableFees = await TrySimulatePayableFeesAsync( product, rulesSnapshot, - zoneKeys, chargeableWeight, cargoAttribute, input.WarehouseId, warehouseName, isRemote, remoteZoneKeys, + countryCode, + input.Province, + input.City, + input.ZipCode, input.DeclaredSum, input.DeclaredCurrency, priceVersion, @@ -316,13 +319,16 @@ public partial class SalesProductService private async Task> TrySimulatePayableFeesAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto rulesSnapshot, - IReadOnlyList zoneKeys, decimal chargeableWeight, string cargoAttribute, int? warehouseId, string? warehouseName, - bool isRemote, - IReadOnlyList remoteZoneKeys, + bool productIsRemote, + IReadOnlyList productRemoteZoneKeys, + string countryCode, + string? province, + string? city, + string? zipCode, decimal? declaredSum, string? declaredCurrency, string priceVersion, @@ -344,7 +350,7 @@ public partial class SalesProductService WarehouseId = warehouseId, DeclaredSum = declaredSum, DeclaredCurrency = declaredCurrency, - IsRemote = isRemote ? (byte)1 : (byte)0 + IsRemote = productIsRemote ? (byte)1 : (byte)0 })).Passed; var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0); if (provider?.ServiceChannelId is not > 0) @@ -353,33 +359,81 @@ public partial class SalesProductService return []; } - var channelPrices = await BuildChannelPriceOutputsAsync(provider.ServiceChannelId.Value); - if (channelPrices.Count == 0) + var channelId = provider.ServiceChannelId.Value; + var channelPriceSnapshot = new ServiceChannelPricePublishSnapshotDto { - warnings.Add($"服务渠道 {provider.ServiceChannelId} 无买价,跳过应付估算"); + ServiceChannelId = channelId, + Prices = (await BuildChannelPriceOutputsAsync(channelId)) + .Select(PublishSnapshotSlimmer.SlimChannelPrice) + .ToList() + }; + if (channelPriceSnapshot.Prices.Count == 0) + { + warnings.Add($"服务渠道 {channelId} 无买价,跳过应付估算"); return []; } - var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == provider.ServiceChannelId.Value); - var payableRemoteZoneKeys = isRemote - ? ParcelBillingCalculator.BuildRemoteZoneKeys( - channel?.RemoteAreaId is > 0 ? channel.RemoteAreaId : null) - : Array.Empty(); - // 渠道未配偏远分区时回落产品偏远键,便于模拟偏远费行 - if (isRemote && payableRemoteZoneKeys.Count == 0) + await PriceAreaSnapshotHelper.ApplyLiveChannelPriceAreaInfosAsync( + channelPriceSnapshot, + KpiAreaRepository, + KpiAreaPackService); + var channelPrices = channelPriceSnapshot.Prices + .Select(PublishSnapshotSlimmer.ToChannelPriceListOutput) + .ToList(); + + IReadOnlyList payableZoneKeys = Array.Empty(); + if (channelPriceSnapshot.PriceAreaInfos is { Count: > 0 }) { - payableRemoteZoneKeys = remoteZoneKeys; + var resolved = ParcelBillingCalculator.TryResolveZoneKeysFromChannelPriceSnapshot( + channelPriceSnapshot, + countryCode, + province, + city, + zipCode); + if (resolved == null || resolved.Count == 0) + { + warnings.Add($"服务渠道 {channelId} 买价分区未匹配,跳过应付估算"); + return []; + } + + payableZoneKeys = resolved; + } + + var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId); + var channelRemoteAreaId = channel?.RemoteAreaId; + var channelIsRemote = productIsRemote; + if (channelRemoteAreaId is > 0) + { + var channelRules = new ServiceChannelRulesPublishSnapshotDto(); + await KpiAreaPackService.LoadLiveRemoteIntoChannelRulesSnapshotAsync( + channelRules, + channelRemoteAreaId); + channelIsRemote = GeoPartitionMatcher.IsHit( + channelRules.RemoteAreaInfos, + countryCode, + province, + city, + zipCode); + } + + var payableRemoteZoneKeys = channelIsRemote + ? ParcelBillingCalculator.BuildRemoteZoneKeys( + channelRemoteAreaId is > 0 ? channelRemoteAreaId : null) + : Array.Empty(); + if (channelIsRemote && payableRemoteZoneKeys.Count == 0) + { + payableRemoteZoneKeys = productRemoteZoneKeys; } var payableContext = OrderRuleEvaluationContext.FromOrderOnly(new Order { ProductCode = product.ProductCode, - ConsigneeCountry = null, + ConsigneeCountry = countryCode, CargoAttribute = cargoAttribute, WarehouseId = warehouseId, DeclaredSum = declaredSum, DeclaredCurrency = declaredCurrency, - IsRemote = isRemote ? (byte)1 : (byte)0 + IsRemote = channelIsRemote ? (byte)1 : (byte)0 }); var payable = ParcelBillingCalculator.CalculatePayableFees( @@ -387,7 +441,7 @@ public partial class SalesProductService priceVersion, priceEffectiveTime, chargeableWeight, - zoneKeys, + payableZoneKeys, cargoAttribute, OrderFeeChargingStageCodes.Forecast, warehouseId, @@ -399,11 +453,11 @@ public partial class SalesProductService if (payable.Count == 0) { - warnings.Add($"服务渠道 {provider.ServiceChannelId} 买价未匹配,跳过应付估算"); + warnings.Add($"服务渠道 {channelId} 买价未匹配,跳过应付估算"); return []; } - warnings.Add($"应付按排货服务渠道 {provider.ServiceChannelId} 估算(首个可用渠道)"); + warnings.Add($"应付按排货服务渠道 {channelId} 估算(首个可用渠道,渠道价区)"); return payable.Select(x => new SalesProductBillingSimulateFeeOutput { CostItem = x.CostItem, diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index 1317ee5..e3c2f22 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -243,23 +243,29 @@ public partial class ServiceChannelService } /// - /// 组装买价发布快照(不落库),与产品卖价 §5.3.2 对称。 + /// 组装买价发布快照(不落库):钉渠道自有价区包。 /// - private async Task BuildPricePublishSnapshotAsync(int serviceChannelId) + private async Task BuildPricePublishSnapshotAsync(int serviceChannelId) { - return new ServiceChannelPricePublishSnapshot + var snapshot = new ServiceChannelPricePublishSnapshotDto { ServiceChannelId = serviceChannelId, Prices = (await GetPriceOutputsAsync(serviceChannelId)) .Select(PublishSnapshotSlimmer.SlimChannelPrice) .ToList() }; + await PriceAreaSnapshotHelper.ApplyChannelPriceAreaToSnapshotAsync( + snapshot, + KpiAreaRepository, + KpiAreaPackService, + ResolvePublishUser()); + return snapshot; } /// - /// 组装渠道规则发布快照(不落库)。 + /// 组装渠道规则发布快照(不落库):钉偏远分区包。 /// - private async Task BuildRulesPublishSnapshotAsync(int serviceChannelId) + private async Task BuildRulesPublishSnapshotAsync(int serviceChannelId) { var entity = await GetEntityOrThrowAsync(serviceChannelId); ServiceChannelDownstreamSnapshotDto? downstream = null; @@ -272,13 +278,39 @@ public partial class ServiceChannelService } } - return new ServiceChannelRulesPublishSnapshot + var snapshot = new ServiceChannelRulesPublishSnapshotDto { Channel = MapChannelToSaveInput(entity), - Downstream = downstream + Downstream = downstream, + RemoteAreaInfos = [] }; + + if (entity.RemoteAreaId is > 0) + { + var packRef = await KpiAreaPackService.EnsurePackAsync( + entity.RemoteAreaId.Value, + KpiAreaPackService.AreaTypeRemote, + ResolvePublishUser(), + description: "service-channel-rules-publish"); + snapshot.RemoteAreaId = entity.RemoteAreaId; + snapshot.RemoteAreaName = packRef.AreaName; + snapshot.RemoteAreaRef = packRef; + } + else + { + snapshot.RemoteAreaId = null; + snapshot.RemoteAreaName = null; + snapshot.RemoteAreaRef = null; + } + + return snapshot; } + private string ResolvePublishUser() => + string.IsNullOrWhiteSpace(CurrentUser.UserName) + ? CurrentUser.GetId().ToString() + : CurrentUser.UserName!; + private static ServiceChannelSaveInput MapChannelToSaveInput(ServiceChannel entity) => new() { @@ -334,7 +366,7 @@ public partial class ServiceChannelService if (operatorType == PriceOperatorType) { - var snapshot = OmsDocHelper.DeserializeJson(record.Content); + var snapshot = OmsDocHelper.DeserializeChannelPriceSnapshot(record.Content); if (snapshot == null) { throw new Fake.Application.BusinessException("发布快照无效"); @@ -349,8 +381,8 @@ public partial class ServiceChannelService return; } - var rulesSnapshot = OmsDocHelper.DeserializeJson(record.Content); - if (rulesSnapshot == null) + var rulesSnapshot = OmsDocHelper.DeserializeJson(record.Content); + if (rulesSnapshot?.Channel == null) { throw new Fake.Application.BusinessException("发布快照无效"); } @@ -658,17 +690,4 @@ public partial class ServiceChannelService }; } - private sealed class ServiceChannelPricePublishSnapshot - { - public int ServiceChannelId { get; set; } - - public List Prices { get; set; } = []; - } - - private sealed class ServiceChannelRulesPublishSnapshot - { - public ServiceChannelSaveInput Channel { get; set; } = new(); - - public ServiceChannelDownstreamSnapshotDto? Downstream { get; set; } - } } diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index a3b535d..ba4c2e5 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -20,6 +20,8 @@ public partial class ServiceChannelService : ApplicationService private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService(); private IServiceChannelGroupRepository ServiceChannelGroupRepository => LazyServiceProvider.GetRequiredService(); + private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService(); + private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService(); private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher => LazyServiceProvider.GetRequiredService(); private OmsRbacUserDisplayNameResolver UserDisplayNameResolver => @@ -400,6 +402,63 @@ public partial class ServiceChannelService : ApplicationService return OmsDocHelper.ToSnapshotApiContent(snapshot)!; } + /// + /// 发布同步态(轻量):对比待发布与最新生效快照,不回传正文。与产品卖价对称。 + /// + [HttpGet("/channel/service-channel/publish/sync-status")] + public async Task GetPublishSyncStatusAsync( + [FromQuery] int serviceChannelId, + [FromQuery] string operatorType) + { + var normalized = NormalizeOperatorType(operatorType); + await GetEntityOrThrowAsync(serviceChannelId); + + object pendingSnapshot = normalized == PriceOperatorType + ? await BuildPricePublishSnapshotAsync(serviceChannelId) + : await BuildRulesPublishSnapshotAsync(serviceChannelId); + var pendingJson = OmsDocHelper.SerializeSnapshotJson(pendingSnapshot) ?? string.Empty; + var pendingHash = ComputeContentHash(pendingJson); + + var onlineRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync( + serviceChannelId, + normalized, + DateTime.Now); + if (onlineRecord == null) + { + return new ServiceChannelPublishSyncStatusOutput + { + Pending = true, + OnlineVersion = null, + OnlinePublishTime = null, + PendingContentHash = pendingHash, + OnlineContentHash = null + }; + } + + var onlineJson = onlineRecord.Content ?? string.Empty; + object? onlineNormalized = normalized == PriceOperatorType + ? OmsDocHelper.DeserializeChannelPriceSnapshot(onlineJson) + : OmsDocHelper.DeserializeJson(onlineJson); + var onlineCanon = OmsDocHelper.SerializeSnapshotJson(onlineNormalized) ?? string.Empty; + var onlineHash = ComputeContentHash(onlineCanon); + + return new ServiceChannelPublishSyncStatusOutput + { + Pending = !string.Equals(pendingHash, onlineHash, StringComparison.Ordinal), + OnlineVersion = onlineRecord.Version, + OnlinePublishTime = onlineRecord.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 659aed6..2fd317d 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -1057,8 +1057,9 @@ public partial class OrderService continue; } - var channelIsRemote = await IsChannelRemoteHitAsync( - channel, + var channelRulesBinding = await ResolveChannelRulesBindingAsync(channel, bindTime); + var channelIsRemote = IsChannelRemoteHit( + channelRulesBinding, order.ConsigneeCountry ?? string.Empty, recipient?.Province, recipient?.City, @@ -1068,15 +1069,38 @@ public partial class OrderService channelIsRemote ? (byte)1 : (byte)0); var channelPriceBinding = await ResolveChannelPriceBindingAsync(channel.Id, bindTime); + var channelZoneKeys = ResolveChannelZoneKeysOrSkip( + channelPriceBinding, + order.ConsigneeCountry ?? string.Empty, + recipient?.Province, + recipient?.City, + recipient?.ZipCode, + out var channelZoneMissReason); + if (channelZoneKeys == null) + { + skipped.Add(new OrderAllocationSkippedOutput + { + ServiceChannelId = channel.Id, + ChannelCode = channel.ChannelCode, + ChannelName = channel.ChannelName, + Stage = AllocationResolver.SkipStageBuyPrice, + MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition), + Reason = channelZoneMissReason + ?? $"渠道买价分区未匹配(版本 {channelPriceBinding.PriceVersion})" + }); + continue; + } + var channelRemoteZoneKeys = channelIsRemote - ? ParcelBillingCalculator.BuildRemoteZoneKeys(channel.RemoteAreaId) + ? ParcelBillingCalculator.BuildRemoteZoneKeys( + channelRulesBinding.RemoteAreaId ?? channel.RemoteAreaId) : Array.Empty(); var previewFees = ParcelBillingCalculator.CalculatePayableFees( channelPriceBinding.Prices, channelPriceBinding.PriceVersion, channelPriceBinding.PriceEffectiveTime, chargeableWeight, - zoneKeys, + channelZoneKeys, cargoAttribute, chargingStage, order.WarehouseId, @@ -1090,7 +1114,7 @@ public partial class OrderService var miss = ParcelBillingCalculator.ExplainPayablePriceMiss( channelPriceBinding.Prices, chargeableWeight, - zoneKeys, + channelZoneKeys, cargoAttribute, order.WarehouseId, warehouseName, @@ -1369,6 +1393,15 @@ public partial class OrderService public string PriceVersion { get; init; } = string.Empty; public DateTime PriceEffectiveTime { get; init; } + + public ServiceChannelPricePublishSnapshotDto Snapshot { get; init; } = new(); + } + + private sealed class ChannelRulesBinding + { + public int? RemoteAreaId { get; init; } + + public ServiceChannelRulesPublishSnapshotDto Snapshot { get; init; } = new(); } private async Task ResolveChannelPriceBindingAsync(int serviceChannelId, DateTime bindTime) @@ -1382,23 +1415,157 @@ public partial class OrderService var snapshot = OmsDocHelper.DeserializeChannelPriceSnapshot(priceRecord.Content); if (snapshot?.Prices.Count > 0) { + await KpiAreaPackService.HydrateChannelPriceSnapshotAsync(snapshot); return new ChannelPriceBinding { Prices = snapshot.Prices.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput).ToList(), PriceVersion = priceRecord.Version, - PriceEffectiveTime = priceRecord.PublishTime + PriceEffectiveTime = priceRecord.PublishTime, + Snapshot = snapshot }; } } + var liveSnapshot = await BuildLiveChannelPriceSnapshotAsync(serviceChannelId); return new ChannelPriceBinding { - Prices = await BuildChannelPriceOutputsAsync(serviceChannelId), + Prices = liveSnapshot.Prices.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput).ToList(), PriceVersion = $"LIVE-{serviceChannelId}", - PriceEffectiveTime = bindTime + PriceEffectiveTime = bindTime, + Snapshot = liveSnapshot }; } + private async Task ResolveChannelRulesBindingAsync( + ServiceChannel channel, + DateTime bindTime) + { + var rulesRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync( + channel.Id, + RulesOperatorType, + bindTime); + if (rulesRecord?.Content != null) + { + var snapshot = OmsDocHelper.DeserializeJson(rulesRecord.Content); + if (snapshot != null) + { + await KpiAreaPackService.HydrateChannelRulesSnapshotAsync(snapshot); + return new ChannelRulesBinding + { + RemoteAreaId = snapshot.RemoteAreaRef?.AreaId + ?? snapshot.RemoteAreaId + ?? snapshot.Channel?.RemoteAreaId + ?? channel.RemoteAreaId, + Snapshot = snapshot + }; + } + } + + var live = new ServiceChannelRulesPublishSnapshotDto + { + Channel = new ServiceChannelSaveInput + { + Id = channel.Id, + RemoteAreaId = channel.RemoteAreaId + }, + RemoteAreaInfos = [] + }; + await KpiAreaPackService.LoadLiveRemoteIntoChannelRulesSnapshotAsync( + live, + channel.RemoteAreaId); + return new ChannelRulesBinding + { + RemoteAreaId = channel.RemoteAreaId, + Snapshot = live + }; + } + + private async Task BuildLiveChannelPriceSnapshotAsync( + int serviceChannelId) + { + var snapshot = new ServiceChannelPricePublishSnapshotDto + { + ServiceChannelId = serviceChannelId, + Prices = (await BuildChannelPriceOutputsAsync(serviceChannelId)) + .Select(PublishSnapshotSlimmer.SlimChannelPrice) + .ToList() + }; + await PriceAreaSnapshotHelper.ApplyLiveChannelPriceAreaInfosAsync( + snapshot, + KpiAreaRepository, + KpiAreaPackService); + return snapshot; + } + + /// + /// 按渠道买价价区包解析 zoneKeys;有价区配置但未命中时返回 null(跳过该渠道)。 + /// 无价区配置时返回空列表(不回落产品卖价分区)。 + /// + private static IReadOnlyList? ResolveChannelZoneKeysOrSkip( + ChannelPriceBinding binding, + string countryCode, + string? province, + string? city, + string? zipCode, + out string? missReason) + { + missReason = null; + var snapshot = binding.Snapshot; + if (snapshot.PriceAreaRefs is { Count: > 0 } + && snapshot.PriceAreaInfos is not { Count: > 0 }) + { + missReason = + $"渠道价格分区包未加载: refs={snapshot.PriceAreaRefs.Count} version={binding.PriceVersion}"; + return null; + } + + if (snapshot.PriceAreaInfos is not { Count: > 0 }) + { + return Array.Empty(); + } + + var keys = ParcelBillingCalculator.TryResolveZoneKeysFromChannelPriceSnapshot( + snapshot, + countryCode, + province, + city, + zipCode); + if (keys == null || keys.Count == 0) + { + missReason = $"渠道买价分区未匹配(版本 {binding.PriceVersion})"; + return null; + } + + return keys; + } + + private static bool IsChannelRemoteHit( + ChannelRulesBinding binding, + string countryCode, + string? province, + string? city, + string? zipCode) + { + var snapshot = binding.Snapshot; + if (snapshot.RemoteAreaRef != null && snapshot.RemoteAreaInfos is not { Count: > 0 }) + { + throw new BusinessException( + $"渠道偏远分区包未加载: areaId={snapshot.RemoteAreaRef.AreaId} version={snapshot.RemoteAreaRef.PackVersion}"); + } + + if (snapshot.RemoteAreaInfos is { Count: > 0 }) + { + return GeoPartitionMatcher.IsHit( + snapshot.RemoteAreaInfos, + countryCode, + province, + city, + zipCode); + } + + return false; + } + private async Task BuildLiveRulesSnapshotAsync(SalesProduct product) { var snapshot = new SalesProductRulesPublishSnapshotDto @@ -2970,19 +3137,4 @@ public partial class OrderService return OrderRuleEvaluationContext.FromOrderOnly(evalOrder); } - private async Task IsChannelRemoteHitAsync( - ServiceChannel channel, - string countryCode, - string? province, - string? city, - string? zipCode) - { - if (channel.RemoteAreaId is not > 0) - { - return false; - } - - var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(channel.RemoteAreaId.Value); - return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode); - } } diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index 5c5762c..e73d78d 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -107,15 +107,53 @@ internal static class ParcelBillingCalculator string countryCode, string? province, string? city, + string? zipCode) => + TryResolveZoneKeysFromAreaInfos( + priceSnapshot?.PriceAreaInfos, + priceSnapshot?.PriceAreaId, + priceSnapshot?.PriceAreaName, + countryCode, + province, + city, + zipCode); + + /// + /// 从渠道买价快照分区规则解析分区键(不查库;与产品卖价独立)。 + /// + public static IReadOnlyList? TryResolveZoneKeysFromChannelPriceSnapshot( + ServiceChannelPricePublishSnapshotDto? priceSnapshot, + string countryCode, + string? province, + string? city, + string? zipCode) => + TryResolveZoneKeysFromAreaInfos( + priceSnapshot?.PriceAreaInfos, + priceSnapshot?.PriceAreaId, + priceSnapshot?.PriceAreaName, + countryCode, + province, + city, + zipCode); + + /// + /// 按分区规则列表匹配地址并生成 zone 键。 + /// + public static IReadOnlyList? TryResolveZoneKeysFromAreaInfos( + IReadOnlyList? priceAreaInfos, + int? priceAreaId, + string? priceAreaName, + string countryCode, + string? province, + string? city, string? zipCode) { - if (priceSnapshot?.PriceAreaInfos == null || priceSnapshot.PriceAreaInfos.Count == 0) + if (priceAreaInfos == null || priceAreaInfos.Count == 0) { return null; } var matched = GeoPartitionMatcher.MatchSnapshot( - priceSnapshot.PriceAreaInfos, + priceAreaInfos, countryCode, province, city, @@ -125,11 +163,10 @@ internal static class ParcelBillingCalculator return null; } - // 多价格分区表时优先用规则自身 AreaId,避免落到快照单一 PriceAreaId - var areaId = matched.AreaId > 0 ? matched.AreaId : priceSnapshot.PriceAreaId; + var areaId = matched.AreaId > 0 ? matched.AreaId : priceAreaId; var areaName = !string.IsNullOrWhiteSpace(matched.AreaName) ? matched.AreaName - : priceSnapshot.PriceAreaName; + : priceAreaName; var keys = BuildPriceZoneKeys(matched, areaName, areaId); return keys.Count > 0 ? keys : null; } @@ -476,10 +513,22 @@ internal static class ParcelBillingCalculator /// /// 收集卖价快照中所有启用价格项的明细分区键(去重)。 /// - public static IReadOnlyList CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot) + public static IReadOnlyList CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot) => + CollectDistinctDetailZoneKeys( + priceSnapshot.Prices.Select(p => (p.Status, p.Details))); + + /// + /// 收集渠道买价快照中所有启用价格项的明细分区键(去重)。 + /// + public static IReadOnlyList CollectDistinctDetailZoneKeys(ServiceChannelPricePublishSnapshotDto priceSnapshot) => + CollectDistinctDetailZoneKeys( + priceSnapshot.Prices.Select(p => (p.Status, p.Details))); + + private static IReadOnlyList CollectDistinctDetailZoneKeys( + IEnumerable<(byte Status, List? Details)> prices) { var zones = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var price in priceSnapshot.Prices.Where(x => EnableStatusRules.IsEnabled(x.Status))) + foreach (var price in prices.Where(x => EnableStatusRules.IsEnabled(x.Status))) { foreach (var detail in price.Details ?? []) { diff --git a/src/Oms.Application/Shared/KpiAreaPackService.cs b/src/Oms.Application/Shared/KpiAreaPackService.cs index 4eb0ac3..bdc8b4f 100644 --- a/src/Oms.Application/Shared/KpiAreaPackService.cs +++ b/src/Oms.Application/Shared/KpiAreaPackService.cs @@ -3,6 +3,7 @@ using System.Text; using Fake.Application; using Fake.DependencyInjection; using Microsoft.Extensions.Caching.Memory; +using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; @@ -254,6 +255,131 @@ public class KpiAreaPackService : IScopedDependency } } + /// + /// 渠道买价:Refs hydrate 到 Infos。 + /// + public async Task HydrateChannelPriceSnapshotAsync( + ServiceChannelPricePublishSnapshotDto snapshot, + CancellationToken cancellationToken = default) + { + 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。 + /// + public async Task HydrateChannelRulesSnapshotAsync( + ServiceChannelRulesPublishSnapshotDto snapshot, + CancellationToken cancellationToken = default) + { + if (snapshot.RemoteAreaRef != null) + { + snapshot.RemoteAreaInfos = await GetRulesOrThrowAsync(snapshot.RemoteAreaRef, cancellationToken); + snapshot.RemoteAreaId = snapshot.RemoteAreaRef.AreaId; + snapshot.RemoteAreaName = snapshot.RemoteAreaRef.AreaName; + } + } + + public async Task LoadLiveInfosIntoChannelPriceSnapshotAsync( + ServiceChannelPricePublishSnapshotDto 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 LoadLiveRemoteIntoChannelRulesSnapshotAsync( + ServiceChannelRulesPublishSnapshotDto snapshot, + int? remoteAreaId, + CancellationToken cancellationToken = default) + { + snapshot.RemoteAreaRef = null; + 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 = []; + } + } + public async Task LoadLiveInfosIntoRulesSnapshotAsync( SalesProductRulesPublishSnapshotDto snapshot, int? blacklistAreaId, diff --git a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs index fbcdcae..0f99dfa 100644 --- a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs +++ b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs @@ -127,6 +127,96 @@ public static class PriceAreaSnapshotHelper return ids.Count > 0 ? ids[0] : null; } + /// + /// 渠道买价发布:从明细推断价区表并钉包(与产品卖价分区独立)。 + /// + public static async Task ApplyChannelPriceAreaToSnapshotAsync( + ServiceChannelPricePublishSnapshotDto snapshot, + IKpiAreaRepository kpiAreaRepository, + KpiAreaPackService packService, + string? publishUser = null, + CancellationToken cancellationToken = default) + { + var areaIds = await CollectResolvedChannelPriceAreaIdsAsync( + snapshot, + kpiAreaRepository, + cancellationToken); + snapshot.PriceAreaInfos = []; + if (areaIds.Count == 0) + { + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = null; + snapshot.PriceAreaRefs = []; + return; + } + + var refs = new List(); + foreach (var areaId in areaIds) + { + refs.Add(await packService.EnsurePackAsync( + areaId, + KpiAreaPackService.AreaTypePrice, + publishUser, + description: "service-channel-price-publish", + cancellationToken)); + } + + snapshot.PriceAreaRefs = refs; + if (refs.Count == 1) + { + snapshot.PriceAreaId = refs[0].AreaId; + snapshot.PriceAreaName = refs[0].AreaName; + } + else + { + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = string.Join( + "、", + refs.Select(x => x.AreaName).Where(x => !string.IsNullOrWhiteSpace(x))); + } + } + + /// + /// 渠道买价 LIVE:不钉包,从当前库装 Infos。 + /// + public static async Task ApplyLiveChannelPriceAreaInfosAsync( + ServiceChannelPricePublishSnapshotDto snapshot, + IKpiAreaRepository kpiAreaRepository, + KpiAreaPackService packService, + CancellationToken cancellationToken = default) + { + var areaIds = await CollectResolvedChannelPriceAreaIdsAsync( + snapshot, + kpiAreaRepository, + cancellationToken); + await packService.LoadLiveInfosIntoChannelPriceSnapshotAsync(snapshot, areaIds, cancellationToken); + } + + internal static async Task> CollectResolvedChannelPriceAreaIdsAsync( + ServiceChannelPricePublishSnapshotDto snapshot, + IKpiAreaRepository kpiAreaRepository, + CancellationToken cancellationToken = default) + { + var result = new List(); + var seen = new HashSet(); + + void Add(int? areaId) + { + if (areaId is > 0 && seen.Add(areaId.Value)) + { + result.Add(areaId.Value); + } + } + + var zoneKeys = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshot); + foreach (var zoneKey in zoneKeys) + { + Add(await TryResolveZoneKeyToAreaIdAsync(zoneKey, kpiAreaRepository, cancellationToken)); + } + + return result; + } + private static async Task TryResolveZoneKeyToAreaIdAsync( string zoneKey, IKpiAreaRepository kpiAreaRepository, diff --git a/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs b/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs index 2eee1df..3f11120 100644 --- a/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs +++ b/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs @@ -38,6 +38,7 @@ public static class PublishSnapshotNormalizer return; } + NormalizeKpiAreas(snapshot.PriceAreaInfos); snapshot.Prices = SortChannelPriceItems(snapshot.Prices); }