From cc100d40cf6ae608d809ba7039675d283bed69d8 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Sun, 2 Aug 2026 18:22:17 +0800 Subject: [PATCH] feat: support remote-zone factor matching and all-customer price tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split price/remote zone matching in billing and prefer the 全部客户 column for any price tier key. Co-authored-by: Cursor --- .../SalesProductService.BillingSimulate.cs | 24 ++- .../Orders/OrderService.ParcelBilling.cs | 16 +- .../Orders/OrderService.ParcelQuote.cs | 9 +- .../Orders/ParcelBillingCalculator.cs | 139 ++++++++++++++++-- src/Oms.Tests/ParcelBillingCalculatorTests.cs | 58 ++++++++ .../Support/ParcelBillingTestData.cs | 31 ++++ 6 files changed, 256 insertions(+), 21 deletions(-) diff --git a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs index 8d7f591..e8ff699 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs @@ -118,6 +118,11 @@ public partial class SalesProductService : OmsDocHelper.SerializeJson(additionalServices) }); + var remoteAreaId = product.RemoteAreaId is > 0 ? product.RemoteAreaId : rulesSnapshot.RemoteAreaId; + var remoteZoneKeys = isRemote + ? ParcelBillingCalculator.BuildRemoteZoneKeys(remoteAreaId, rulesSnapshot.RemoteAreaName) + : Array.Empty(); + var receivableLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines( priceSnapshot, priceVersion, @@ -130,7 +135,8 @@ public partial class SalesProductService warehouseName, conditionContext, input.DeclaredSum, - input.DeclaredCurrency); + input.DeclaredCurrency, + remoteZoneKeys); if (receivableLines.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(priceSnapshot)) { @@ -165,6 +171,7 @@ public partial class SalesProductService input.WarehouseId, warehouseName, isRemote, + remoteZoneKeys, input.DeclaredSum, input.DeclaredCurrency, priceVersion, @@ -307,6 +314,7 @@ public partial class SalesProductService int? warehouseId, string? warehouseName, bool isRemote, + IReadOnlyList remoteZoneKeys, decimal? declaredSum, string? declaredCurrency, string priceVersion, @@ -333,6 +341,17 @@ public partial class SalesProductService return []; } + var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == provider.ServiceChannelId.Value); + var payableRemoteZoneKeys = isRemote + ? ParcelBillingCalculator.BuildRemoteZoneKeys( + channel?.RemoteAreaId is > 0 ? channel.RemoteAreaId : null) + : Array.Empty(); + // 渠道未配偏远分区时回落产品偏远键,便于模拟偏远费行 + if (isRemote && payableRemoteZoneKeys.Count == 0) + { + payableRemoteZoneKeys = remoteZoneKeys; + } + var payableContext = OrderRuleEvaluationContext.FromOrderOnly(new Order { ProductCode = product.ProductCode, @@ -356,7 +375,8 @@ public partial class SalesProductService warehouseName, payableContext, declaredSum, - declaredCurrency); + declaredCurrency, + payableRemoteZoneKeys); if (payable.Count == 0) { diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 3a9d009..5108eb0 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -689,6 +689,13 @@ public partial class OrderService string? warehouseName = null) { var conditionContext = BuildBillingConditionContext(order); + var remoteAreaId = order.IsRemote > 0 + ? (snapshotBinding.Rules?.RemoteAreaId + ?? snapshotBinding.Rules?.Product?.RemoteAreaId) + : null; + var remoteZoneKeys = ParcelBillingCalculator.BuildRemoteZoneKeys( + remoteAreaId is > 0 ? remoteAreaId : null, + snapshotBinding.Rules?.RemoteAreaName); var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshotBinding.Price, snapshotBinding.PriceVersion, @@ -702,7 +709,8 @@ public partial class OrderService warehouseName, conditionContext, order.DeclaredSum, - order.DeclaredCurrency); + order.DeclaredCurrency, + remoteZoneKeys); if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price)) { @@ -781,6 +789,9 @@ public partial class OrderService channelIsRemote ? (byte)1 : (byte)0); var channelPriceBinding = await ResolveChannelPriceBindingAsync(channel.Id, bindTime); + var channelRemoteZoneKeys = channelIsRemote + ? ParcelBillingCalculator.BuildRemoteZoneKeys(channel.RemoteAreaId) + : Array.Empty(); var previewFees = ParcelBillingCalculator.CalculatePayableFees( channelPriceBinding.Prices, channelPriceBinding.PriceVersion, @@ -793,7 +804,8 @@ public partial class OrderService warehouseName, payableContext, order.DeclaredSum, - order.DeclaredCurrency); + order.DeclaredCurrency, + channelRemoteZoneKeys); if (previewFees.Count == 0) { continue; diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index fbfee95..1c0de66 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -595,6 +595,12 @@ public partial class OrderService context.PostalCode); var conditionContext = BuildQuoteBillingConditionContext(context, isRemote); + var remoteZoneKeys = isRemote + ? ParcelBillingCalculator.BuildRemoteZoneKeys( + ResolveEffectiveRemoteAreaId(product, snapshotBinding.Rules), + snapshotBinding.Rules?.RemoteAreaName) + : Array.Empty(); + foreach (var zoneAttempt in zoneAttempts) { var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines( @@ -609,7 +615,8 @@ public partial class OrderService context.WarehouseName, conditionContext, context.DeclaredSum, - context.DeclaredCurrency); + context.DeclaredCurrency, + remoteZoneKeys); if (feeLines.Count == 0) { continue; diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index 20507b7..d833543 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -374,14 +374,34 @@ internal static class ParcelBillingCalculator }; /// - /// 卖价/买价明细匹配共用上下文(分区、货物属性、计费重、仓库)。 + /// 卖价/买价明细匹配共用上下文(价格分区、偏远分区、货物属性、计费重、仓库)。 /// internal readonly record struct PriceDetailMatchContext( IReadOnlyList ZoneKeys, string CargoAttribute, decimal ChargeableWeight, int? WarehouseId = null, - string? WarehouseName = null); + string? WarehouseName = null, + IReadOnlyList? RemoteZoneKeys = null); + + /// + /// 偏远命中时用生效 RemoteAreaId(及可选名称)作为偏远分区匹配键;非偏远返回空。 + /// + public static IReadOnlyList BuildRemoteZoneKeys(int? remoteAreaId, string? remoteAreaName = null) + { + if (remoteAreaId is not > 0) + { + return Array.Empty(); + } + + var keys = new List { remoteAreaId.Value.ToString(CultureInfo.InvariantCulture) }; + if (!string.IsNullOrWhiteSpace(remoteAreaName)) + { + keys.Add(remoteAreaName.Trim()); + } + + return keys; + } public static List CalculateQuoteReceivableFees( SalesProductPricePublishSnapshotDto priceSnapshot, @@ -395,7 +415,8 @@ internal static class ParcelBillingCalculator string? warehouseName = null, OrderRuleEvaluationContext? conditionContext = null, decimal? declaredSum = null, - string? declaredCurrency = null) => + string? declaredCurrency = null, + IReadOnlyList? remoteZoneKeys = null) => CalculateQuoteReceivableFeeLines( priceSnapshot, priceVersion, @@ -408,7 +429,8 @@ internal static class ParcelBillingCalculator warehouseName, conditionContext, declaredSum, - declaredCurrency).Select(x => x.Fee).ToList(); + declaredCurrency, + remoteZoneKeys).Select(x => x.Fee).ToList(); public static List CalculateQuoteReceivableFeeLines( SalesProductPricePublishSnapshotDto priceSnapshot, @@ -422,7 +444,8 @@ internal static class ParcelBillingCalculator string? warehouseName = null, OrderRuleEvaluationContext? conditionContext = null, decimal? declaredSum = null, - string? declaredCurrency = null) + string? declaredCurrency = null, + IReadOnlyList? remoteZoneKeys = null) { var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem( p.Status, p.CostItem, p.PriceName, p.Currency, p.Details, @@ -440,7 +463,8 @@ internal static class ParcelBillingCalculator warehouseName, conditionContext, declaredSum, - declaredCurrency); + declaredCurrency, + remoteZoneKeys); } public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => @@ -656,7 +680,8 @@ internal static class ParcelBillingCalculator string? warehouseName = null, OrderRuleEvaluationContext? conditionContext = null, decimal? declaredSum = null, - string? declaredCurrency = null) + string? declaredCurrency = null, + IReadOnlyList? remoteZoneKeys = null) { var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem( p.Status, p.CostItem, p.PriceName, p.Currency, p.Details, @@ -674,7 +699,8 @@ internal static class ParcelBillingCalculator warehouseName, conditionContext, declaredSum, - declaredCurrency); + declaredCurrency, + remoteZoneKeys); } /// @@ -692,7 +718,8 @@ internal static class ParcelBillingCalculator string? warehouseName = null, OrderRuleEvaluationContext? conditionContext = null, decimal? declaredSum = null, - string? declaredCurrency = null) + string? declaredCurrency = null, + IReadOnlyList? remoteZoneKeys = null) { var items = prices.Select(p => new PayableBillingPriceItem( p.Status, p.CostItem, p.Currency, p.Details, @@ -701,7 +728,13 @@ internal static class ParcelBillingCalculator items, priceVersion, priceEffectiveTime, - new PriceDetailMatchContext(zoneKeys, cargoAttribute, chargeableWeight, warehouseId, warehouseName), + new PriceDetailMatchContext( + zoneKeys, + cargoAttribute, + chargeableWeight, + warehouseId, + warehouseName, + remoteZoneKeys), chargingStage, conditionContext, declaredSum, @@ -899,7 +932,8 @@ internal static class ParcelBillingCalculator string? warehouseName = null, OrderRuleEvaluationContext? conditionContext = null, decimal? declaredSum = null, - string? declaredCurrency = null) => + string? declaredCurrency = null, + IReadOnlyList? remoteZoneKeys = null) => CalculateReceivableFeeLines( priceItems, priceVersion, @@ -913,7 +947,8 @@ internal static class ParcelBillingCalculator warehouseName, conditionContext, declaredSum, - declaredCurrency).Select(x => x.Fee).ToList(); + declaredCurrency, + remoteZoneKeys).Select(x => x.Fee).ToList(); private static List CalculateReceivableFeeLines( IEnumerable priceItems, @@ -928,7 +963,8 @@ internal static class ParcelBillingCalculator string? warehouseName = null, OrderRuleEvaluationContext? conditionContext = null, decimal? declaredSum = null, - string? declaredCurrency = null) + string? declaredCurrency = null, + IReadOnlyList? remoteZoneKeys = null) { var pass1 = new List<(int SerialNumber, BillingFeeLine Line)>(); var percentFreightPending = new List<(int SerialNumber, ReceivableBillingPriceItem PriceItem, ChannelPriceDetailOutput Detail)>(); @@ -951,7 +987,8 @@ internal static class ParcelBillingCalculator cargoAttribute, chargeableWeight, warehouseId, - warehouseName); + warehouseName, + remoteZoneKeys); if (detail == null) { continue; @@ -1084,7 +1121,8 @@ internal static class ParcelBillingCalculator match.CargoAttribute, match.ChargeableWeight, match.WarehouseId, - match.WarehouseName); + match.WarehouseName, + match.RemoteZoneKeys); if (detail == null) { continue; @@ -1269,6 +1307,7 @@ internal static class ParcelBillingCalculator { var factors = ParseFactorDictionary(candidate.FactorValuesJson); if (!ZoneMatches(factors, match.ZoneKeys) + || !RemoteZoneMatches(factors, match.RemoteZoneKeys) || !MatchCargoTypeFactor(factors, match.CargoAttribute) || !MatchWarehouseFactor(factors, match.WarehouseId, match.WarehouseName)) { @@ -1343,7 +1382,8 @@ internal static class ParcelBillingCalculator string cargoAttribute, decimal chargeableWeight, int? warehouseId = null, - string? warehouseName = null) + string? warehouseName = null, + IReadOnlyList? remoteZoneKeys = null) { ChannelPriceDetailOutput? bestMatch = null; var bestIntervalWidth = decimal.MaxValue; @@ -1356,6 +1396,11 @@ internal static class ParcelBillingCalculator continue; } + if (!RemoteZoneMatches(factors, remoteZoneKeys)) + { + continue; + } + if (!MatchCargoTypeFactor(factors, cargoAttribute)) { continue; @@ -1446,6 +1491,24 @@ internal static class ParcelBillingCalculator return zoneKeys.Any(key => string.Equals(key.Trim(), expected, StringComparison.OrdinalIgnoreCase)); } + private static bool RemoteZoneMatches(Dictionary factors, IReadOnlyList? remoteZoneKeys) + { + if (!TryGetRemoteZoneFactorText(factors, out var remoteZoneValue)) + { + return true; + } + + if (remoteZoneKeys == null || remoteZoneKeys.Count == 0) + { + return false; + } + + var expected = remoteZoneValue.Trim(); + return remoteZoneKeys.Any(key => + !string.IsNullOrWhiteSpace(key) + && string.Equals(key.Trim(), expected, StringComparison.OrdinalIgnoreCase)); + } + private static bool TryGetZoneFactorText(Dictionary factors, out string zoneValue) { if (TryGetFactorValue(factors, out zoneValue, "分区", "价格分区", "zone")) @@ -1457,6 +1520,17 @@ internal static class ParcelBillingCalculator return false; } + private static bool TryGetRemoteZoneFactorText(Dictionary factors, out string remoteZoneValue) + { + if (TryGetFactorValue(factors, out remoteZoneValue, "偏远分区", "remoteZone")) + { + return !string.IsNullOrWhiteSpace(remoteZoneValue); + } + + remoteZoneValue = string.Empty; + return false; + } + private static bool TryGetWeightFactorText(Dictionary factors, out string weightText) => TryGetFactorValue(factors, out weightText, "重量", "weight") && !string.IsNullOrWhiteSpace(weightText); @@ -1810,6 +1884,7 @@ internal static class ParcelBillingCalculator continue; } + // 续重邻档总价查找不按偏远分区过滤:邻档通常与运费同组,偏远费单独成项。 if (NormalizeCalculationFormula(candidate.CalculationFormula) != "total") { continue; @@ -1933,6 +2008,12 @@ internal static class ParcelBillingCalculator return null; } + // 「全部客户」优先:任意价区键共用这一列 + if (TryGetMapPrice(map, out var allPrice, "全部客户", "all")) + { + return allPrice; + } + if (!string.IsNullOrWhiteSpace(priceTierKey) && map.TryGetValue(priceTierKey.Trim(), out var tierPrice)) { @@ -1947,6 +2028,32 @@ internal static class ParcelBillingCalculator return null; } + private static bool TryGetMapPrice( + Dictionary map, + out decimal price, + params string[] keys) + { + foreach (var key in keys) + { + if (map.TryGetValue(key, out price)) + { + return true; + } + + foreach (var pair in map) + { + if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)) + { + price = pair.Value; + return true; + } + } + } + + price = 0; + return false; + } + private static bool EvaluateCondition(string condition, decimal gw, decimal vw) { if (string.IsNullOrWhiteSpace(condition)) diff --git a/src/Oms.Tests/ParcelBillingCalculatorTests.cs b/src/Oms.Tests/ParcelBillingCalculatorTests.cs index 2279e47..91b621d 100644 --- a/src/Oms.Tests/ParcelBillingCalculatorTests.cs +++ b/src/Oms.Tests/ParcelBillingCalculatorTests.cs @@ -557,5 +557,63 @@ public class ParcelBillingCalculatorTests fees[0].Amount.Should().Be(12M); } + [Fact] + public void CalculateReceivableFees_AllCustomerColumn_ShouldApplyToAnyPriceTierKey() + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + 1, + "freight", + ParcelBillingTestData.AllCustomerTierDetail(Zone, 33M))); + + var fees = ParcelBillingCalculator.CalculateReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 1.2M, + new[] { Zone }, + "general", + "VIP5", + OrderFeeChargingStageCodes.Forecast); + + fees.Should().ContainSingle(); + fees[0].Amount.Should().Be(33M); + } + + [Fact] + public void CalculateReceivableFees_RemoteZoneFactor_ShouldRequireMatchingRemoteKeys() + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + 1, + "remoteFee", + ParcelBillingTestData.RemoteZoneFixedDetail(15M, Zone, "88"))); + + var miss = ParcelBillingCalculator.CalculateReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 1.2M, + new[] { Zone }, + "general", + null, + OrderFeeChargingStageCodes.Forecast, + remoteZoneKeys: Array.Empty()); + miss.Should().BeEmpty(); + + var hit = ParcelBillingCalculator.CalculateReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 1.2M, + new[] { Zone }, + "general", + null, + OrderFeeChargingStageCodes.Forecast, + remoteZoneKeys: ParcelBillingCalculator.BuildRemoteZoneKeys(88)); + hit.Should().ContainSingle(); + hit[0].Amount.Should().Be(15M); + } + #endregion } diff --git a/src/Oms.Tests/Support/ParcelBillingTestData.cs b/src/Oms.Tests/Support/ParcelBillingTestData.cs index 56c4ee2..e970796 100644 --- a/src/Oms.Tests/Support/ParcelBillingTestData.cs +++ b/src/Oms.Tests/Support/ParcelBillingTestData.cs @@ -150,6 +150,37 @@ internal static class ParcelBillingTestData CustomerGroupValues = new Dictionary { ["普通客户"] = normalCustomerPrice } }; + public static ChannelPriceDetailOutput AllCustomerTierDetail( + string zone, + decimal allCustomerPrice, + string? weightInterval = null) => + new() + { + CalculationFormula = "total", + CalculatedValue = 0, + FactorValuesJson = new Dictionary + { + ["价格分区"] = zone, + ["重量"] = weightInterval ?? "999" + }, + CustomerGroupValues = new Dictionary { ["全部客户"] = allCustomerPrice } + }; + + public static ChannelPriceDetailOutput RemoteZoneFixedDetail( + decimal amount, + string priceZone, + string remoteZoneId) => + new() + { + CalculationFormula = "固定价", + CalculatedValue = amount, + FactorValuesJson = new Dictionary + { + ["价格分区"] = priceZone, + ["偏远分区"] = remoteZoneId + } + }; + public static WeightRuleInfo GwRule(string condition = "gw>=vw", string factor = "6000") => new() {