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()
{