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