diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs index 8fadcc1..5fac4a4 100644 --- a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs +++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs @@ -12,6 +12,21 @@ public class SalesProductPricePublishSnapshotDto /// public int SalesProductId { get; set; } + /// + /// 价格分区 ID(发布时固化,查价/计费优先用快照内分区规则)。 + /// + public int? PriceAreaId { get; set; } + + /// + /// 价格分区名称。 + /// + public string? PriceAreaName { get; set; } + + /// + /// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化。 + /// + public List PriceAreaInfos { get; set; } = []; + /// /// 价格项列表。 /// diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 67bcd7f..5c6ba2f 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -1,4 +1,5 @@ using Oms.Application.Dtos.Channels; +using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Shared; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Consts; @@ -160,13 +161,20 @@ public partial class SalesProductService private async Task CreatePublishRecordAsync(int salesProductId, string operatorType, string? description) { var product = await GetProductOrThrowAsync(salesProductId); - object snapshot = operatorType == PriceOperatorType - ? new SalesProductPricePublishSnapshot + object snapshot; + if (operatorType == PriceOperatorType) + { + var priceSnapshot = new SalesProductPricePublishSnapshot { SalesProductId = salesProductId, Prices = await GetPriceOutputsAsync(salesProductId) - } - : new SalesProductRulesPublishSnapshot + }; + await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(priceSnapshot, product, KpiAreaRepository); + snapshot = priceSnapshot; + } + else + { + snapshot = new SalesProductRulesPublishSnapshot { Product = new SalesProductSaveInput { @@ -189,6 +197,7 @@ public partial class SalesProductService }, Providers = (await GetAllocationCoreAsync(salesProductId)).Providers }; + } // Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。 await SalesProductRepository.InsertPublishRecordAsync(new SalesProductPublishRecord @@ -421,11 +430,8 @@ public partial class SalesProductService }; } - private sealed class SalesProductPricePublishSnapshot + private sealed class SalesProductPricePublishSnapshot : SalesProductPricePublishSnapshotDto { - public int SalesProductId { get; set; } - - public List Prices { get; set; } = []; } private sealed class SalesProductRulesPublishSnapshot diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index 807dd09..2fe6b3d 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -20,6 +20,7 @@ public partial class SalesProductService : ApplicationService private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService(); private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService(); + private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService(); /// /// 查询销售产品列表。 diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index b64264e..6e55317 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -89,6 +89,7 @@ public partial class OrderService var zoneKeys = await ResolvePriceZoneKeysAsync( product, snapshotBinding.Rules, + snapshotBinding.Price, country.CountryCode, input.Receiver?.Province, input.Receiver?.City, @@ -317,7 +318,7 @@ public partial class OrderService .GroupBy(x => x.PriceId) .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()); - return new SalesProductPricePublishSnapshotDto + var snapshot = new SalesProductPricePublishSnapshotDto { SalesProductId = product.Id, Prices = prices.Select(price => new SalesProductPriceListOutput @@ -340,6 +341,8 @@ public partial class OrderService : [] }).ToList() }; + await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, KpiAreaRepository); + return snapshot; } private static ChannelPriceFactorOutput MapPriceFactorOutput(SalesProductPriceFactor entity) @@ -411,7 +414,8 @@ public partial class OrderService private static int? ResolveEffectivePriceAreaId( SalesProduct product, - SalesProductRulesPublishSnapshotDto? rulesSnapshot) + SalesProductRulesPublishSnapshotDto? rulesSnapshot, + SalesProductPricePublishSnapshotDto? priceSnapshot) { if (product.PriceAreaId is > 0) { @@ -423,18 +427,34 @@ public partial class OrderService return rulesSnapshot.Product.PriceAreaId; } + if (priceSnapshot?.PriceAreaId is > 0) + { + return priceSnapshot.PriceAreaId; + } + return null; } private async Task?> ResolvePriceZoneKeysAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, + SalesProductPricePublishSnapshotDto? priceSnapshot, string countryCode, string? province, string? city, string? zipCode) { - var priceAreaId = ResolveEffectivePriceAreaId(product, rulesSnapshot); + if (priceSnapshot?.PriceAreaInfos is { Count: > 0 }) + { + return ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot( + priceSnapshot, + countryCode, + province, + city, + zipCode); + } + + var priceAreaId = ResolveEffectivePriceAreaId(product, rulesSnapshot, priceSnapshot); if (!priceAreaId.HasValue) { return null; diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index 37e05de..f1740da 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -217,7 +217,7 @@ public partial class OrderService return PriceCheckFailure(product, "weight", ex.Message); } - var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules); + var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price); if (!priceAreaId.HasValue) { var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price); @@ -233,6 +233,7 @@ public partial class OrderService var zoneKeys = await ResolvePriceZoneKeysAsync( product, snapshotBinding.Rules, + snapshotBinding.Price, context.CountryCode, context.Province, context.City, @@ -246,8 +247,8 @@ public partial class OrderService return PriceCheckFailure( product, "zone", - $"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格分区规则(PriceAreaId={priceAreaId})," + - "无法解析与卖价一致的分区键,请检查产品 PriceAreaId 及 t_kpi_area_info 国家/邮编配置"); + $"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格快照内分区规则(PriceAreaId={priceAreaId})," + + "请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致"); } var chargeableWeight = forecast.ChargeableWeight ?? 0; diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index 48b5ca6..f0434b7 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -122,6 +122,40 @@ internal static class ParcelBillingCalculator return true; } + public static bool IsAreaMatched( + KpiAreaInfoOutput rule, + string countryCode, + string? province, + string? city, + string? zipCode) => + IsAreaMatched(MapInfoOutputToEntity(rule), countryCode, province, city, zipCode); + + /// + /// 从卖价快照内固化的分区规则解析分区键(不查库)。 + /// + public static IReadOnlyList? TryResolveZoneKeysFromPriceSnapshot( + SalesProductPricePublishSnapshotDto? priceSnapshot, + string countryCode, + string? province, + string? city, + string? zipCode) + { + if (priceSnapshot?.PriceAreaInfos == null || priceSnapshot.PriceAreaInfos.Count == 0) + { + return null; + } + + var matched = priceSnapshot.PriceAreaInfos.FirstOrDefault(rule => + IsAreaMatched(rule, countryCode, province, city, zipCode)); + if (matched == null) + { + return null; + } + + var keys = BuildPriceZoneKeys(matched, priceSnapshot.PriceAreaName, priceSnapshot.PriceAreaId); + return keys.Count > 0 ? keys : null; + } + public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area) { if (matched == null) @@ -187,6 +221,75 @@ internal static class ParcelBillingCalculator return keys.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } + public static IReadOnlyList BuildPriceZoneKeys( + KpiAreaInfoOutput matched, + string? areaName, + int? areaId) + { + var keys = new List(); + var name = ResolveZoneName(matched, areaName); + if (!string.IsNullOrWhiteSpace(name)) + { + keys.Add(name); + } + + keys.Add(matched.Id.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(matched.Province)) + { + keys.Add(matched.Province); + } + + if (!string.IsNullOrWhiteSpace(matched.CountryName)) + { + keys.Add(matched.CountryName); + } + + if (areaId is > 0) + { + keys.Add(areaId.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (!string.IsNullOrWhiteSpace(areaName)) + { + keys.Add(areaName); + } + + return keys.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + public static string? ResolveZoneName(KpiAreaInfoOutput matched, string? areaName) + { + if (!string.IsNullOrWhiteSpace(matched.Province)) + { + return matched.Province; + } + + if (!string.IsNullOrWhiteSpace(matched.City)) + { + return matched.City; + } + + if (!string.IsNullOrWhiteSpace(matched.CountryName)) + { + return matched.CountryName; + } + + return areaName; + } + + private static KpiAreaInfo MapInfoOutputToEntity(KpiAreaInfoOutput rule) => + new() + { + Id = rule.Id, + AreaId = rule.AreaId, + CountryCode = rule.CountryCode, + CountryName = rule.CountryName, + Province = rule.Province, + City = rule.City, + LeftPostcode = rule.LeftPostcode, + RightPostcode = rule.RightPostcode + }; + public static List CalculateQuoteReceivableFees( SalesProductPricePublishSnapshotDto priceSnapshot, string priceVersion, diff --git a/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs new file mode 100644 index 0000000..d06e44f --- /dev/null +++ b/src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs @@ -0,0 +1,51 @@ +using Oms.Application.Dtos.Channels; +using Oms.Application.Dtos.Orders; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.ChannelsAggregate.Repositories; + +namespace Oms.Application.Shared; + +/// +/// 将价格分区(t_kpi_area + t_kpi_area_info)固化进卖价发布快照。 +/// +public static class PriceAreaSnapshotHelper +{ + /// + /// 按产品 PriceAreaId 加载分区头与明细写入快照。 + /// + public static async Task ApplyPriceAreaToSnapshotAsync( + SalesProductPricePublishSnapshotDto snapshot, + SalesProduct product, + IKpiAreaRepository kpiAreaRepository, + CancellationToken cancellationToken = default) + { + if (!product.PriceAreaId.HasValue || product.PriceAreaId <= 0) + { + snapshot.PriceAreaId = null; + snapshot.PriceAreaName = null; + snapshot.PriceAreaInfos = []; + return; + } + + snapshot.PriceAreaId = product.PriceAreaId; + var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == product.PriceAreaId.Value, cancellationToken); + snapshot.PriceAreaName = area?.Name; + var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(product.PriceAreaId.Value, cancellationToken); + snapshot.PriceAreaInfos = infos.Select(MapInfoOutput).ToList(); + } + + private static KpiAreaInfoOutput MapInfoOutput(KpiAreaInfo entity) => + new() + { + Id = entity.Id, + AreaId = entity.AreaId, + CountryCode = entity.CountryCode, + CountryName = entity.CountryName, + Province = entity.Province, + City = entity.City, + LeftPostcode = entity.LeftPostcode, + RightPostcode = entity.RightPostcode, + CreateTime = entity.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + }; +} diff --git a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs index 72e6e9d..866197a 100644 --- a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs +++ b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs @@ -142,6 +142,47 @@ public class PriceCheckSnapshotVerificationTests fees.Sum(x => x.Amount).Should().Be(31M); } + [Fact] + public void TryResolveZoneKeysFromPriceSnapshot_UsZip06000_ShouldMatchZone3() + { + var snapshot = new SalesProductPricePublishSnapshotDto + { + PriceAreaId = 3, + PriceAreaName = "美国分区", + PriceAreaInfos = + [ + new KpiAreaInfoOutput + { + Id = 4, + AreaId = 3, + CountryCode = "US", + CountryName = "美国", + LeftPostcode = "50000", + RightPostcode = "90000" + }, + new KpiAreaInfoOutput + { + Id = 3, + AreaId = 3, + CountryCode = "US", + CountryName = "美国", + LeftPostcode = "00000", + RightPostcode = "50000" + } + ] + }; + + var keys = ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot( + snapshot, + "US", + null, + null, + "06000"); + + keys.Should().NotBeNull(); + keys!.Should().Contain("3"); + } + [Fact] public void ExplainQuotePriceMiss_ZoneMismatch_ShouldNameEachPriceItem() {