feat: 渠道买价/规则发布钉 KPI 分区包,排货应付按渠道价区解析
All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s

各渠道异构价区与偏远不再复用产品卖价 zoneKeys;发布只存包引用,计费 hydrate 后硬失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-07 17:19:39 +08:00
parent bb8c0a906c
commit 35156eedf4
11 changed files with 676 additions and 75 deletions

View File

@ -0,0 +1,22 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 服务渠道发布同步态(轻量,不含快照正文)。
/// </summary>
public class ServiceChannelPublishSyncStatusOutput
{
/// <summary>相对最新生效版本是否有待发布变更。</summary>
public bool Pending { get; set; }
/// <summary>最新生效版本号;无则 null。</summary>
public string? OnlineVersion { get; set; }
/// <summary>最新生效发布时间。</summary>
public DateTime? OnlinePublishTime { get; set; }
/// <summary>待发布快照内容哈希(规范化后)。</summary>
public string? PendingContentHash { get; set; }
/// <summary>线上快照内容哈希。</summary>
public string? OnlineContentHash { get; set; }
}

View File

@ -1,3 +1,6 @@
using System.Text.Json.Serialization;
using Oms.Application.Dtos.Orders;
namespace Oms.Application.Dtos.Channels;
/// <summary>
@ -8,4 +11,17 @@ public class ServiceChannelRulesPublishSnapshotDto
public ServiceChannelSaveInput? Channel { get; set; }
public ServiceChannelDownstreamSnapshotDto? Downstream { get; set; }
/// <summary>偏远分区 ID。</summary>
public int? RemoteAreaId { get; set; }
/// <summary>偏远分区名称。</summary>
public string? RemoteAreaName { get; set; }
/// <summary>偏远分区包引用(规则发布时钉包)。</summary>
public KpiAreaPackRefDto? RemoteAreaRef { get; set; }
/// <summary>运行时 hydrate / LIVE 用,不落发布 JSON。</summary>
[JsonIgnore]
public List<KpiAreaSnapshotRuleDto> RemoteAreaInfos { get; set; } = [];
}

View File

@ -129,12 +129,25 @@ public class SalesProductRulesPublishSnapshotDto
}
/// <summary>
/// 服务渠道买价发布快照。
/// 服务渠道买价发布快照(渠道自有价区,与产品卖价分区独立)。
/// </summary>
public class ServiceChannelPricePublishSnapshotDto
{
public int ServiceChannelId { get; set; }
/// <summary>价格分区 ID(单表时冗余展示;以 PriceAreaRefs 为准)。</summary>
public int? PriceAreaId { get; set; }
/// <summary>价格分区名称。</summary>
public string? PriceAreaName { get; set; }
/// <summary>渠道价区包引用(买价发布时钉包)。</summary>
public List<KpiAreaPackRefDto> PriceAreaRefs { get; set; } = [];
/// <summary>运行时 hydrate / LIVE 用,不落发布 JSON。</summary>
[JsonIgnore]
public List<KpiAreaSnapshotRuleDto> PriceAreaInfos { get; set; } = [];
public List<ServiceChannelPriceSnapshotItemDto> Prices { get; set; } = [];
}

View File

@ -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<List<SalesProductBillingSimulateFeeOutput>> TrySimulatePayableFeesAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto rulesSnapshot,
IReadOnlyList<string> zoneKeys,
decimal chargeableWeight,
string cargoAttribute,
int? warehouseId,
string? warehouseName,
bool isRemote,
IReadOnlyList<string> remoteZoneKeys,
bool productIsRemote,
IReadOnlyList<string> 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<string>();
// 渠道未配偏远分区时回落产品偏远键,便于模拟偏远费行
if (isRemote && payableRemoteZoneKeys.Count == 0)
await PriceAreaSnapshotHelper.ApplyLiveChannelPriceAreaInfosAsync(
channelPriceSnapshot,
KpiAreaRepository,
KpiAreaPackService);
var channelPrices = channelPriceSnapshot.Prices
.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput)
.ToList();
IReadOnlyList<string> payableZoneKeys = Array.Empty<string>();
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<string>();
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,

View File

@ -243,23 +243,29 @@ public partial class ServiceChannelService
}
/// <summary>
/// 组装买价发布快照(不落库),与产品卖价 §5.3.2 对称。
/// 组装买价发布快照(不落库):钉渠道自有价区包。
/// </summary>
private async Task<ServiceChannelPricePublishSnapshot> BuildPricePublishSnapshotAsync(int serviceChannelId)
private async Task<ServiceChannelPricePublishSnapshotDto> 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;
}
/// <summary>
/// 组装渠道规则发布快照(不落库)。
/// 组装渠道规则发布快照(不落库):钉偏远分区包。
/// </summary>
private async Task<ServiceChannelRulesPublishSnapshot> BuildRulesPublishSnapshotAsync(int serviceChannelId)
private async Task<ServiceChannelRulesPublishSnapshotDto> BuildRulesPublishSnapshotAsync(int serviceChannelId)
{
var entity = await GetEntityOrThrowAsync(serviceChannelId);
ServiceChannelDownstreamSnapshotDto? downstream = null;
@ -272,12 +278,38 @@ 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<int>().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<ServiceChannelPricePublishSnapshot>(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<ServiceChannelRulesPublishSnapshot>(record.Content);
if (rulesSnapshot == null)
var rulesSnapshot = OmsDocHelper.DeserializeJson<ServiceChannelRulesPublishSnapshotDto>(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<ServiceChannelPriceSnapshotItemDto> Prices { get; set; } = [];
}
private sealed class ServiceChannelRulesPublishSnapshot
{
public ServiceChannelSaveInput Channel { get; set; } = new();
public ServiceChannelDownstreamSnapshotDto? Downstream { get; set; }
}
}

View File

@ -20,6 +20,8 @@ public partial class ServiceChannelService : ApplicationService
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
private IServiceChannelGroupRepository ServiceChannelGroupRepository => LazyServiceProvider.GetRequiredService<IServiceChannelGroupRepository>();
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService<KpiAreaPackService>();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
@ -400,6 +402,63 @@ public partial class ServiceChannelService : ApplicationService
return OmsDocHelper.ToSnapshotApiContent(snapshot)!;
}
/// <summary>
/// 发布同步态(轻量):对比待发布与最新生效快照,不回传正文。与产品卖价对称。
/// </summary>
[HttpGet("/channel/service-channel/publish/sync-status")]
public async Task<ServiceChannelPublishSyncStatusOutput> 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<ServiceChannelRulesPublishSnapshotDto>(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();
}
/// <summary>
/// 查询服务渠道发布规则列表。
/// </summary>

View File

@ -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<string>();
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<ChannelPriceBinding> 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<ChannelRulesBinding> ResolveChannelRulesBindingAsync(
ServiceChannel channel,
DateTime bindTime)
{
var rulesRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync(
channel.Id,
RulesOperatorType,
bindTime);
if (rulesRecord?.Content != null)
{
var snapshot = OmsDocHelper.DeserializeJson<ServiceChannelRulesPublishSnapshotDto>(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<ServiceChannelPricePublishSnapshotDto> 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;
}
/// <summary>
/// 按渠道买价价区包解析 zoneKeys;有价区配置但未命中时返回 null(跳过该渠道)。
/// 无价区配置时返回空列表(不回落产品卖价分区)。
/// </summary>
private static IReadOnlyList<string>? 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<string>();
}
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<SalesProductRulesPublishSnapshotDto> BuildLiveRulesSnapshotAsync(SalesProduct product)
{
var snapshot = new SalesProductRulesPublishSnapshotDto
@ -2970,19 +3137,4 @@ public partial class OrderService
return OrderRuleEvaluationContext.FromOrderOnly(evalOrder);
}
private async Task<bool> 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);
}
}

View File

@ -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);
/// <summary>
/// 从渠道买价快照分区规则解析分区键(不查库;与产品卖价独立)。
/// </summary>
public static IReadOnlyList<string>? TryResolveZoneKeysFromChannelPriceSnapshot(
ServiceChannelPricePublishSnapshotDto? priceSnapshot,
string countryCode,
string? province,
string? city,
string? zipCode) =>
TryResolveZoneKeysFromAreaInfos(
priceSnapshot?.PriceAreaInfos,
priceSnapshot?.PriceAreaId,
priceSnapshot?.PriceAreaName,
countryCode,
province,
city,
zipCode);
/// <summary>
/// 按分区规则列表匹配地址并生成 zone 键。
/// </summary>
public static IReadOnlyList<string>? TryResolveZoneKeysFromAreaInfos(
IReadOnlyList<KpiAreaSnapshotRuleDto>? 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
/// <summary>
/// 收集卖价快照中所有启用价格项的明细分区键(去重)。
/// </summary>
public static IReadOnlyList<string> CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot)
public static IReadOnlyList<string> CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot) =>
CollectDistinctDetailZoneKeys(
priceSnapshot.Prices.Select(p => (p.Status, p.Details)));
/// <summary>
/// 收集渠道买价快照中所有启用价格项的明细分区键(去重)。
/// </summary>
public static IReadOnlyList<string> CollectDistinctDetailZoneKeys(ServiceChannelPricePublishSnapshotDto priceSnapshot) =>
CollectDistinctDetailZoneKeys(
priceSnapshot.Prices.Select(p => (p.Status, p.Details)));
private static IReadOnlyList<string> CollectDistinctDetailZoneKeys(
IEnumerable<(byte Status, List<ChannelPriceDetailOutput>? Details)> prices)
{
var zones = new HashSet<string>(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 ?? [])
{

View File

@ -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
}
}
/// <summary>
/// 渠道买价:Refs hydrate 到 Infos。
/// </summary>
public async Task HydrateChannelPriceSnapshotAsync(
ServiceChannelPricePublishSnapshotDto snapshot,
CancellationToken cancellationToken = default)
{
if (snapshot.PriceAreaRefs is not { Count: > 0 })
{
return;
}
var merged = new List<KpiAreaSnapshotRuleDto>();
var seen = new HashSet<int>();
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)));
}
}
/// <summary>
/// 渠道规则:偏远 Ref hydrate。
/// </summary>
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<int> areaIds,
CancellationToken cancellationToken = default)
{
var merged = new List<KpiAreaSnapshotRuleDto>();
var seen = new HashSet<int>();
var names = new List<string>();
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,

View File

@ -127,6 +127,96 @@ public static class PriceAreaSnapshotHelper
return ids.Count > 0 ? ids[0] : null;
}
/// <summary>
/// 渠道买价发布:从明细推断价区表并钉包(与产品卖价分区独立)。
/// </summary>
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<KpiAreaPackRefDto>();
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)));
}
}
/// <summary>
/// 渠道买价 LIVE:不钉包,从当前库装 Infos。
/// </summary>
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<IReadOnlyList<int>> CollectResolvedChannelPriceAreaIdsAsync(
ServiceChannelPricePublishSnapshotDto snapshot,
IKpiAreaRepository kpiAreaRepository,
CancellationToken cancellationToken = default)
{
var result = new List<int>();
var seen = new HashSet<int>();
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<int?> TryResolveZoneKeyToAreaIdAsync(
string zoneKey,
IKpiAreaRepository kpiAreaRepository,

View File

@ -38,6 +38,7 @@ public static class PublishSnapshotNormalizer
return;
}
NormalizeKpiAreas(snapshot.PriceAreaInfos);
snapshot.Prices = SortChannelPriceItems(snapshot.Prices);
}