fix: 查价失败原因按价格项细化,指出分区/重量等具体不匹配项
新增 ExplainQuotePriceMiss 逐条说明运费/操作费等未命中原因;zone 失败时附带卖价明细分区。单元测试 6/6 通过。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
94a503afb3
commit
cd061aaaed
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user