fix: 查价失败原因按价格项细化,指出分区/重量等具体不匹配项

新增 ExplainQuotePriceMiss 逐条说明运费/操作费等未命中原因;zone 失败时附带卖价明细分区。单元测试 6/6 通过。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-26 10:49:08 +08:00
parent 94a503afb3
commit cd061aaaed
3 changed files with 165 additions and 7 deletions

View File

@ -220,10 +220,14 @@ public partial class OrderService
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules); var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules);
if (!priceAreaId.HasValue) if (!priceAreaId.HasValue)
{ {
var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
var snapshotZoneHint = snapshotZones.Count > 0
? $"卖价明细分区=[{string.Join(", ", snapshotZones)}],"
: string.Empty;
return PriceCheckFailure( return PriceCheckFailure(
product, product,
"zone", "zone",
"产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键"); $"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键");
} }
var zoneKeys = await ResolvePriceZoneKeysAsync( var zoneKeys = await ResolvePriceZoneKeysAsync(
@ -235,11 +239,15 @@ public partial class OrderService
context.PostalCode); context.PostalCode);
if (zoneKeys == null || zoneKeys.Count == 0) if (zoneKeys == null || zoneKeys.Count == 0)
{ {
var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
var snapshotZoneHint = snapshotZones.Count > 0
? $"卖价明细分区=[{string.Join(", ", snapshotZones)}]。"
: string.Empty;
return PriceCheckFailure( return PriceCheckFailure(
product, product,
"zone", "zone",
$"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格分区规则(PriceAreaId={priceAreaId})," + $"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格分区规则(PriceAreaId={priceAreaId})," +
$"卖价明细分区键(如 3) 无法解析,请检查 t_kpi_area_info 国家/邮编配置"); "无法解析与卖价一致的分区键,请检查产品 PriceAreaId 及 t_kpi_area_info 国家/邮编配置");
} }
var chargeableWeight = forecast.ChargeableWeight ?? 0; var chargeableWeight = forecast.ChargeableWeight ?? 0;
@ -254,8 +262,7 @@ public partial class OrderService
if (fees.Count == 0) if (fees.Count == 0)
{ {
var enabledItems = snapshotBinding.Price.Prices.Count(x => x.Status == 1); if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
if (enabledItems == 0)
{ {
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项"); return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项");
} }
@ -263,8 +270,12 @@ public partial class OrderService
return PriceCheckFailure( return PriceCheckFailure(
product, product,
"price", "price",
$"计费重 {chargeableWeight}kg,匹配分区键 [{string.Join(", ", zoneKeys)}],货物属性 {context.CargoAttribute}," + ParcelBillingCalculator.ExplainQuotePriceMiss(
$"在卖价版本 {snapshotBinding.PriceVersion} 的 {enabledItems} 个启用价格项中未命中任何明细(请检查分区/重量区间/货物类型)"); snapshotBinding.Price,
chargeableWeight,
zoneKeys,
context.CargoAttribute,
customerGroup));
} }
var isRemote = await IsRemoteHitAsync( var isRemote = await IsRemoteHitAsync(

View File

@ -213,6 +213,136 @@ internal static class ParcelBillingCalculator
public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
priceSnapshot.Prices.Any(x => x.Status == 1); priceSnapshot.Prices.Any(x => x.Status == 1);
/// <summary>
/// 收集卖价快照中所有启用价格项的明细分区键(去重)。
/// </summary>
public static IReadOnlyList<string> CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot)
{
var zones = new HashSet<string>(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();
}
/// <summary>
/// 查价未命中时,按价格项输出具体原因(分区/货物类型/重量区间等)。
/// </summary>
public static string ExplainQuotePriceMiss(
SalesProductPricePublishSnapshotDto priceSnapshot,
decimal chargeableWeight,
IReadOnlyList<string> 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<string>();
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<string>();
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<OrderFee> CalculateForecastReceivableFees( public static List<OrderFee> CalculateForecastReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot, SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion, string priceVersion,

View File

@ -142,6 +142,23 @@ public class PriceCheckSnapshotVerificationTests
fees.Sum(x => x.Amount).Should().Be(31M); 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] [Fact]
public void UserSnapshot_WrongZone_ShouldReturnNoFees() public void UserSnapshot_WrongZone_ShouldReturnNoFees()
{ {