diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
index 918e17b..37e05de 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
@@ -220,10 +220,14 @@ public partial class OrderService
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules);
if (!priceAreaId.HasValue)
{
+ var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
+ var snapshotZoneHint = snapshotZones.Count > 0
+ ? $"卖价明细分区=[{string.Join(", ", snapshotZones)}],"
+ : string.Empty;
return PriceCheckFailure(
product,
"zone",
- "产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键");
+ $"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键");
}
var zoneKeys = await ResolvePriceZoneKeysAsync(
@@ -235,11 +239,15 @@ public partial class OrderService
context.PostalCode);
if (zoneKeys == null || zoneKeys.Count == 0)
{
+ var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
+ var snapshotZoneHint = snapshotZones.Count > 0
+ ? $"卖价明细分区=[{string.Join(", ", snapshotZones)}]。"
+ : string.Empty;
return PriceCheckFailure(
product,
"zone",
- $"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格分区规则(PriceAreaId={priceAreaId})," +
- $"卖价明细分区键(如 3) 无法解析,请检查 t_kpi_area_info 国家/邮编配置");
+ $"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格分区规则(PriceAreaId={priceAreaId})," +
+ "无法解析与卖价一致的分区键,请检查产品 PriceAreaId 及 t_kpi_area_info 国家/邮编配置");
}
var chargeableWeight = forecast.ChargeableWeight ?? 0;
@@ -254,8 +262,7 @@ public partial class OrderService
if (fees.Count == 0)
{
- var enabledItems = snapshotBinding.Price.Prices.Count(x => x.Status == 1);
- if (enabledItems == 0)
+ if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
{
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项");
}
@@ -263,8 +270,12 @@ public partial class OrderService
return PriceCheckFailure(
product,
"price",
- $"计费重 {chargeableWeight}kg,匹配分区键 [{string.Join(", ", zoneKeys)}],货物属性 {context.CargoAttribute}," +
- $"在卖价版本 {snapshotBinding.PriceVersion} 的 {enabledItems} 个启用价格项中未命中任何明细(请检查分区/重量区间/货物类型)");
+ ParcelBillingCalculator.ExplainQuotePriceMiss(
+ snapshotBinding.Price,
+ chargeableWeight,
+ zoneKeys,
+ context.CargoAttribute,
+ customerGroup));
}
var isRemote = await IsRemoteHitAsync(
diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
index dce1f3a..48b5ca6 100644
--- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
+++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
@@ -213,6 +213,136 @@ internal static class ParcelBillingCalculator
public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
priceSnapshot.Prices.Any(x => x.Status == 1);
+ ///
+ /// 收集卖价快照中所有启用价格项的明细分区键(去重)。
+ ///
+ public static IReadOnlyList CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot)
+ {
+ var zones = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var price in priceSnapshot.Prices.Where(x => x.Status == 1))
+ {
+ foreach (var detail in price.Details ?? [])
+ {
+ var factors = ParseFactorDictionary(detail.FactorValuesJson);
+ if (TryGetZoneFactorText(factors, out var zone))
+ {
+ zones.Add(zone.Trim());
+ }
+ }
+ }
+
+ return zones.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ ///
+ /// 查价未命中时,按价格项输出具体原因(分区/货物类型/重量区间等)。
+ ///
+ public static string ExplainQuotePriceMiss(
+ SalesProductPricePublishSnapshotDto priceSnapshot,
+ decimal chargeableWeight,
+ IReadOnlyList zoneKeys,
+ string cargoAttribute,
+ CustomerGroup? customerGroup)
+ {
+ var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
+ var enabledPrices = priceSnapshot.Prices.Where(x => x.Status == 1).ToList();
+ if (enabledPrices.Count == 0)
+ {
+ return "卖价快照中无启用的价格项";
+ }
+
+ var reasons = new List();
+ foreach (var price in enabledPrices)
+ {
+ var label = !string.IsNullOrWhiteSpace(price.PriceName) ? price.PriceName! : price.CostItem;
+ var details = price.Details ?? [];
+ if (details.Count == 0)
+ {
+ reasons.Add($"【{label}】价格项Id={price.Id}:无明细行");
+ continue;
+ }
+
+ var zoneMatchedDetails = details.Where(detail =>
+ {
+ var factors = ParseFactorDictionary(detail.FactorValuesJson);
+ return ZoneMatches(factors, zoneKeys);
+ }).ToList();
+
+ if (zoneMatchedDetails.Count == 0)
+ {
+ var detailZones = details
+ .Select(detail =>
+ {
+ var factors = ParseFactorDictionary(detail.FactorValuesJson);
+ return TryGetZoneFactorText(factors, out var zone) ? zone.Trim() : null;
+ })
+ .Where(zone => !string.IsNullOrWhiteSpace(zone))
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+ reasons.Add(
+ $"【{label}】价格项Id={price.Id}:分区不匹配,明细分区=[{string.Join(", ", detailZones)}]," +
+ $"解析分区键=[{string.Join(", ", zoneKeys)}]");
+ continue;
+ }
+
+ var cargoMatchedDetails = zoneMatchedDetails.Where(detail =>
+ {
+ var factors = ParseFactorDictionary(detail.FactorValuesJson);
+ return MatchCargoTypeFactor(factors, cargoLabel);
+ }).ToList();
+
+ if (cargoMatchedDetails.Count == 0)
+ {
+ var requiredCargo = zoneMatchedDetails
+ .Select(detail =>
+ {
+ var factors = ParseFactorDictionary(detail.FactorValuesJson);
+ return TryGetFactorValue(factors, out var cargo, "货物类型", "cargo") ? cargo.Trim() : null;
+ })
+ .Where(cargo => !string.IsNullOrWhiteSpace(cargo))
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+ var requiredText = requiredCargo.Any() ? string.Join(", ", requiredCargo) : "(明细未限定货物类型)";
+ reasons.Add($"【{label}】价格项Id={price.Id}:货物类型不匹配,当前={cargoLabel},明细要求=[{requiredText}]");
+ continue;
+ }
+
+ var weightMatched = false;
+ var intervals = new List();
+ foreach (var detail in cargoMatchedDetails)
+ {
+ var factors = ParseFactorDictionary(detail.FactorValuesJson);
+ if (!TryGetWeightFactorText(factors, out var weightText))
+ {
+ weightMatched = true;
+ break;
+ }
+
+ intervals.Add(weightText);
+ if (TryParseWeightInterval(weightText, out var rangeStart, out var rangeEnd)
+ && WeightInLeftOpenRightClosedInterval(chargeableWeight, rangeStart, rangeEnd))
+ {
+ weightMatched = true;
+ break;
+ }
+ }
+
+ if (!weightMatched)
+ {
+ var intervalText = intervals.Count > 0
+ ? string.Join(", ", intervals.Distinct(StringComparer.OrdinalIgnoreCase))
+ : "无有效重量区间";
+ reasons.Add(
+ $"【{label}】价格项Id={price.Id}:计费重 {chargeableWeight}kg 未落入重量区间 [{intervalText}](左开右闭 (start,end])");
+ continue;
+ }
+
+ var customerGroupLabel = customerGroup?.ToString() ?? "未登录/默认";
+ reasons.Add(
+ $"【{label}】价格项Id={price.Id}:明细已匹配但金额为0或客户组「{customerGroupLabel}」无对应单价");
+ }
+
+ return string.Join(";", reasons);
+ }
+
public static List CalculateForecastReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
diff --git a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
index 357432e..72e6e9d 100644
--- a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
+++ b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
@@ -142,6 +142,23 @@ public class PriceCheckSnapshotVerificationTests
fees.Sum(x => x.Amount).Should().Be(31M);
}
+ [Fact]
+ public void ExplainQuotePriceMiss_ZoneMismatch_ShouldNameEachPriceItem()
+ {
+ var reason = ParcelBillingCalculator.ExplainQuotePriceMiss(
+ BuildUserSnapshot(),
+ 0.5M,
+ ["默认分区"],
+ CargoAttributeCodes.General,
+ CustomerGroup.Normal);
+
+ reason.Should().Contain("分区不匹配");
+ reason.Should().Contain("明细分区=[3]");
+ reason.Should().Contain("解析分区键=[默认分区]");
+ reason.Should().Contain("freight");
+ reason.Should().Contain("handling");
+ }
+
[Fact]
public void UserSnapshot_WrongZone_ShouldReturnNoFees()
{