From cd061aaaed1e53483fdb446c7efcc348dceed449 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 26 Jun 2026 10:49:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=9F=A5=E4=BB=B7=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E6=8C=89=E4=BB=B7=E6=A0=BC=E9=A1=B9=E7=BB=86?= =?UTF-8?q?=E5=8C=96=EF=BC=8C=E6=8C=87=E5=87=BA=E5=88=86=E5=8C=BA/?= =?UTF-8?q?=E9=87=8D=E9=87=8F=E7=AD=89=E5=85=B7=E4=BD=93=E4=B8=8D=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ExplainQuotePriceMiss 逐条说明运费/操作费等未命中原因;zone 失败时附带卖价明细分区。单元测试 6/6 通过。 Co-authored-by: Cursor --- .../Orders/OrderService.ParcelQuote.cs | 25 +++- .../Orders/ParcelBillingCalculator.cs | 130 ++++++++++++++++++ .../PriceCheckSnapshotVerificationTests.cs | 17 +++ 3 files changed, 165 insertions(+), 7 deletions(-) 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() {