diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index e74605a..02b1e42 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -84,13 +84,13 @@ public partial class OrderService SyncWeightRows(weights, forecast); await OrderRepository.ReplaceWeightsAsync(order.Id, weights); - var zoneName = await MatchPriceZoneAsync( + var zoneKeys = await ResolvePriceZoneKeysAsync( product, country.CountryCode, input.Receiver?.Province, input.Receiver?.City, input.Receiver?.ZipCode); - if (string.IsNullOrWhiteSpace(zoneName)) + if (zoneKeys == null || zoneKeys.Count == 0) { throw new BusinessException("价格分区未匹配(规则快照)"); } @@ -101,7 +101,7 @@ public partial class OrderService snapshotBinding.PriceVersion, snapshotBinding.PriceEffectiveTime, forecast.ChargeableWeight ?? 0, - zoneName, + zoneKeys, order.CargoAttribute ?? CargoAttributeCodes.General, customer?.CustomerGroup); @@ -398,7 +398,7 @@ public partial class OrderService zipCode)); } - private async Task MatchPriceZoneAsync( + private async Task?> ResolvePriceZoneKeysAsync( SalesProduct product, string countryCode, string? province, @@ -407,10 +407,15 @@ public partial class OrderService { if (!product.PriceAreaId.HasValue || product.PriceAreaId <= 0) { - return "默认分区"; + return new[] { "默认分区" }; } var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == product.PriceAreaId.Value); + if (area == null) + { + return null; + } + var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.PriceAreaId.Value); var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched( rule, @@ -418,7 +423,8 @@ public partial class OrderService province, city, zipCode)); - return ParcelBillingCalculator.ResolveZoneName(matched, area); + var keys = ParcelBillingCalculator.BuildPriceZoneKeys(matched, area); + return keys.Count > 0 ? keys : null; } private async Task ApplyChargeableWeightAsync(SalesProduct product, OrderWeight forecast) diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index 11defb2..cb0cf53 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -148,27 +148,27 @@ public partial class OrderService var forecast = ParcelBillingCalculator.BuildForecastWeight(context); await ApplyChargeableWeightAsync(product, forecast); - var zoneName = await MatchPriceZoneAsync( + var zoneKeys = await ResolvePriceZoneKeysAsync( product, context.CountryCode, context.Province, context.City, context.PostalCode); - if (string.IsNullOrWhiteSpace(zoneName)) + if (zoneKeys == null || zoneKeys.Count == 0) { return null; } - var fees = ParcelBillingCalculator.CalculateForecastReceivableFees( + var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees( snapshotBinding.Price, snapshotBinding.PriceVersion, snapshotBinding.PriceEffectiveTime, forecast.ChargeableWeight ?? 0, - zoneName, + zoneKeys, context.CargoAttribute, customerGroup); - if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshotBinding.Price)) + if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price)) { return null; } @@ -189,7 +189,7 @@ public partial class OrderService PriceVersion = snapshotBinding.PriceVersion, VolumetricWeight = forecast.VolumetricWeight ?? 0, ChargeableWeight = forecast.ChargeableWeight ?? 0, - ZoneName = zoneName, + ZoneName = zoneKeys[0], IsRemote = isRemote, Fees = fees }; diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index 7ddedd3..668ba9a 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -147,12 +147,52 @@ internal static class ParcelBillingCalculator return area?.Name; } - public static List CalculateForecastReceivableFees( + public static IReadOnlyList BuildPriceZoneKeys(KpiAreaInfo? matched, KpiArea? area) + { + var keys = new List(); + if (matched != null) + { + var name = ResolveZoneName(matched, area); + if (!string.IsNullOrWhiteSpace(name)) + { + keys.Add(name); + } + + keys.Add(matched.Id.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(matched.Province)) + { + keys.Add(matched.Province); + } + + if (!string.IsNullOrWhiteSpace(matched.CountryName)) + { + keys.Add(matched.CountryName); + } + } + + if (area != null) + { + keys.Add(area.Id.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(area.Name)) + { + keys.Add(area.Name); + } + + if (!string.IsNullOrWhiteSpace(area.SystemName)) + { + keys.Add(area.SystemName); + } + } + + return keys.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + public static List CalculateQuoteReceivableFees( SalesProductPricePublishSnapshotDto priceSnapshot, string priceVersion, DateTime priceEffectiveTime, decimal chargeableWeight, - string zoneName, + IReadOnlyList zoneKeys, string cargoAttribute, CustomerGroup? customerGroup) { @@ -164,9 +204,53 @@ internal static class ParcelBillingCalculator priceVersion, priceEffectiveTime, chargeableWeight, - zoneName, + zoneKeys, + cargoAttribute, + customerGroup, + onlyForecastChargingTime: false); + } + + public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => + priceSnapshot.Prices.Any(x => x.Status == 1); + + public static List CalculateForecastReceivableFees( + SalesProductPricePublishSnapshotDto priceSnapshot, + string priceVersion, + DateTime priceEffectiveTime, + decimal chargeableWeight, + string zoneName, + string cargoAttribute, + CustomerGroup? customerGroup) => + CalculateForecastReceivableFees( + priceSnapshot, + priceVersion, + priceEffectiveTime, + chargeableWeight, + new[] { zoneName }, cargoAttribute, customerGroup); + + public static List CalculateForecastReceivableFees( + SalesProductPricePublishSnapshotDto priceSnapshot, + string priceVersion, + DateTime priceEffectiveTime, + decimal chargeableWeight, + IReadOnlyList zoneKeys, + string cargoAttribute, + CustomerGroup? customerGroup) + { + var items = priceSnapshot.Prices.Select(p => new BillingPriceItem( + p.Status, p.ChargingTime, p.CostItem, p.Currency, p.Details)); + return CalculateForecastFees( + items, + OrderFeeTypeCodes.Receivable, + priceVersion, + priceEffectiveTime, + chargeableWeight, + zoneKeys, + cargoAttribute, + customerGroup, + onlyForecastChargingTime: true); } /// @@ -189,9 +273,10 @@ internal static class ParcelBillingCalculator priceVersion, priceEffectiveTime, chargeableWeight, - zoneName, + new[] { zoneName }, cargoAttribute, - customerGroup); + customerGroup, + onlyForecastChargingTime: true); } public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => @@ -294,19 +379,20 @@ internal static class ParcelBillingCalculator string priceVersion, DateTime priceEffectiveTime, decimal chargeableWeight, - string zoneName, + IReadOnlyList zoneKeys, string cargoAttribute, - CustomerGroup? customerGroup) + CustomerGroup? customerGroup, + bool onlyForecastChargingTime) { var fees = new List(); foreach (var priceItem in priceItems.Where(x => x.Status == 1)) { - if (!IsForecastChargingTime(priceItem.ChargingTime)) + if (onlyForecastChargingTime && !IsForecastChargingTime(priceItem.ChargingTime)) { continue; } - var detail = MatchPriceDetail(priceItem.Details, zoneName, cargoAttribute, chargeableWeight); + var detail = MatchPriceDetail(priceItem.Details, zoneKeys, cargoAttribute, chargeableWeight); if (detail == null) { continue; @@ -343,16 +429,19 @@ internal static class ParcelBillingCalculator private static ChannelPriceDetailOutput? MatchPriceDetail( IReadOnlyList details, - string zoneName, + IReadOnlyList zoneKeys, string cargoAttribute, decimal chargeableWeight) { var cargoLabel = GetCargoAttributeLabel(cargoAttribute); + var tierCandidates = new List<(ChannelPriceDetailOutput Detail, decimal Tier)>(); + ChannelPriceDetailOutput? rangeHit = null; + ChannelPriceDetailOutput? genericHit = null; + foreach (var detail in details) { var factors = ParseFactorDictionary(detail.FactorValuesJson); - if (!MatchFactorValue(factors, "分区", zoneName) - && !MatchFactorValue(factors, "价格分区", zoneName)) + if (!ZoneMatches(factors, zoneKeys)) { continue; } @@ -363,15 +452,107 @@ internal static class ParcelBillingCalculator continue; } - if (!MatchWeightRange(factors, chargeableWeight)) + if (TryParseWeightRange(factors, out var rangeStart, out var rangeEnd)) { + if (chargeableWeight >= rangeStart && chargeableWeight <= rangeEnd) + { + rangeHit = detail; + } + continue; } - return detail; + if (TryParseSingleWeightTier(factors, out var tier)) + { + tierCandidates.Add((detail, tier)); + continue; + } + + genericHit = detail; } - return null; + if (rangeHit != null) + { + return rangeHit; + } + + if (tierCandidates.Count > 0) + { + var ceiling = tierCandidates + .Where(x => x.Tier >= chargeableWeight) + .OrderBy(x => x.Tier) + .Select(x => x.Detail) + .FirstOrDefault(); + if (ceiling != null) + { + return ceiling; + } + + return tierCandidates.OrderByDescending(x => x.Tier).First().Detail; + } + + return genericHit; + } + + private static bool ZoneMatches(Dictionary factors, IReadOnlyList zoneKeys) + { + if (!factors.TryGetValue("分区", out var zoneValue) || string.IsNullOrWhiteSpace(zoneValue)) + { + if (!factors.TryGetValue("价格分区", out zoneValue) || string.IsNullOrWhiteSpace(zoneValue)) + { + return true; + } + } + + var expected = zoneValue.Trim(); + return zoneKeys.Any(key => string.Equals(key.Trim(), expected, StringComparison.OrdinalIgnoreCase)); + } + + private static bool TryParseWeightRange( + Dictionary factors, + out decimal start, + out decimal end) + { + start = 0; + end = 0; + if (!factors.TryGetValue("重量", out var rangeText) || string.IsNullOrWhiteSpace(rangeText)) + { + return false; + } + + var parts = rangeText.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2) + { + return false; + } + + if (!decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out start)) + { + return false; + } + + if (!decimal.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out end)) + { + return false; + } + + return true; + } + + private static bool TryParseSingleWeightTier(Dictionary factors, out decimal tier) + { + tier = 0; + if (!factors.TryGetValue("重量", out var text) || string.IsNullOrWhiteSpace(text)) + { + return false; + } + + if (text.Contains('-', StringComparison.Ordinal)) + { + return false; + } + + return decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out tier); } private static Dictionary ParseFactorDictionary(object? factorValuesJson) @@ -404,23 +585,7 @@ internal static class ParcelBillingCalculator private static bool MatchWeightRange(Dictionary factors, decimal chargeableWeight) { - if (!factors.TryGetValue("重量", out var rangeText) || string.IsNullOrWhiteSpace(rangeText)) - { - return true; - } - - var parts = rangeText.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - if (parts.Length != 2) - { - return true; - } - - if (!decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var start)) - { - return true; - } - - if (!decimal.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var end)) + if (!TryParseWeightRange(factors, out var start, out var end)) { return true; } @@ -434,7 +599,7 @@ internal static class ParcelBillingCalculator CustomerGroup? customerGroup) { var formula = detail.CalculationFormula?.Trim() ?? string.Empty; - if (formula is "单价" or "固定价") + if (formula is "单价" or "固定价" or "total" or "总价") { return ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0; } @@ -513,7 +678,7 @@ internal static class ParcelBillingCalculator private static decimal? ResolveCustomerGroupPrice(ChannelPriceDetailOutput detail, CustomerGroup? customerGroup) { - if (!customerGroup.HasValue || detail.CustomerGroupValues == null) + if (detail.CustomerGroupValues == null) { return null; } @@ -525,18 +690,28 @@ internal static class ParcelBillingCalculator return null; } - var key = customerGroup.Value switch + if (customerGroup.HasValue) { - CustomerGroup.Vip1 => "客户组1", - CustomerGroup.Vip2 => "客户组2", - CustomerGroup.Vip3 => "客户组3", - CustomerGroup.Vip4 => "客户组4", - CustomerGroup.Vip5 => "客户组5", - _ => null - }; - if (key != null && map.TryGetValue(key, out var value)) + var key = customerGroup.Value switch + { + CustomerGroup.Normal => "普通客户", + CustomerGroup.Vip1 => "客户组1", + CustomerGroup.Vip2 => "客户组2", + CustomerGroup.Vip3 => "客户组3", + CustomerGroup.Vip4 => "客户组4", + CustomerGroup.Vip5 => "客户组5", + _ => null + }; + + if (key != null && map.TryGetValue(key, out var value)) + { + return value; + } + } + + if (map.TryGetValue("普通客户", out var normalPrice)) { - return value; + return normalPrice; } return null; diff --git a/src/Oms.Tests/ParcelBillingCalculatorDemoTests.cs b/src/Oms.Tests/ParcelBillingCalculatorDemoTests.cs index a4e7c7b..8bc1660 100644 --- a/src/Oms.Tests/ParcelBillingCalculatorDemoTests.cs +++ b/src/Oms.Tests/ParcelBillingCalculatorDemoTests.cs @@ -67,10 +67,11 @@ public class ParcelBillingCalculatorDemoTests var kpiArea = new KpiArea { Id = 1, Name = "美西分区" }; var zoneMatched = ParcelBillingCalculator.IsAreaMatched( kpiRule, quoteContext.CountryCode, quoteContext.Province, quoteContext.City, quoteContext.PostalCode); + var zoneKeys = ParcelBillingCalculator.BuildPriceZoneKeys(kpiRule, kpiArea); var zoneName = ParcelBillingCalculator.ResolveZoneName(kpiRule, kpiArea) ?? "美西"; zoneMatched.Should().BeTrue($"邮编 {quoteContext.PostalCode} 应命中美西分区"); - _output.WriteLine($"分区匹配: {zoneMatched}, 分区名: {zoneName}"); + _output.WriteLine($"分区匹配: {zoneMatched}, 分区名: {zoneName}, 分区键: {string.Join(',', zoneKeys)}"); // —— 3. 计重(可在 ParcelBillingCalculator.CalculateRawBillableWeight 打断点)—— var forecastWeight = ParcelBillingCalculator.BuildForecastWeight(quoteContext); @@ -99,7 +100,7 @@ public class ParcelBillingCalculatorDemoTests priceVersion, effectiveTime, chargeableWeight, - zoneName, + new[] { "美西" }, quoteContext.CargoAttribute, null); @@ -127,7 +128,7 @@ public class ParcelBillingCalculatorDemoTests priceVersion, effectiveTime, chargeableWeight, - zoneName, + "美西", quoteContext.CargoAttribute, null); diff --git a/src/Oms.Tests/ParcelBillingCalculatorTests.cs b/src/Oms.Tests/ParcelBillingCalculatorTests.cs index 9a0b596..f8fa838 100644 --- a/src/Oms.Tests/ParcelBillingCalculatorTests.cs +++ b/src/Oms.Tests/ParcelBillingCalculatorTests.cs @@ -164,6 +164,54 @@ public class ParcelBillingCalculatorTests fees[0].Amount.Should().Be(15M); } + [Fact] + public void CalculateQuoteReceivableFees_ShouldSupportOutboundTotalPriceTiers() + { + var details = new[] + { + ParcelBillingTestData.TotalTierDetail("3", 0.5M, 11M), + ParcelBillingTestData.TotalTierDetail("3", 1M, 12M), + ParcelBillingTestData.TotalTierDetail("3", 1.5M, 12M), + ParcelBillingTestData.TotalTierDetail("3", 2M, 14M), + }; + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem("outbound", 1, "freight", details)); + + var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 1M, + new[] { "3" }, + "general", + null); + + fees.Should().ContainSingle(); + fees[0].Amount.Should().Be(12M); + } + + [Fact] + public void CalculateForecastReceivableFees_ShouldApplyNormalCustomerLabel() + { + var detail = ParcelBillingTestData.TotalTierDetail("3", 1M, 99M); + detail.CustomerGroupValues = new Dictionary { ["普通客户"] = 12M }; + + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", detail)); + + var fees = ParcelBillingCalculator.CalculateForecastReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 1M, + new[] { "3" }, + "general", + CustomerGroup.Normal); + + fees.Should().ContainSingle(); + fees[0].Amount.Should().Be(12M); + } + [Fact] public void HasEnabledForecastPriceItems_ShouldDetectForecastEnabledItems() { diff --git a/src/Oms.Tests/Support/ParcelBillingTestData.cs b/src/Oms.Tests/Support/ParcelBillingTestData.cs index 4be347a..048c350 100644 --- a/src/Oms.Tests/Support/ParcelBillingTestData.cs +++ b/src/Oms.Tests/Support/ParcelBillingTestData.cs @@ -92,6 +92,22 @@ internal static class ParcelBillingTestData FactorValuesJson = new Dictionary { ["分区"] = zone } }; + public static ChannelPriceDetailOutput TotalTierDetail( + string zone, + decimal weightTier, + decimal normalCustomerPrice) => + new() + { + CalculationFormula = "total", + CalculatedValue = 0, + FactorValuesJson = new Dictionary + { + ["分区"] = zone, + ["重量"] = weightTier.ToString(CultureInfo.InvariantCulture) + }, + CustomerGroupValues = new Dictionary { ["普通客户"] = normalCustomerPrice } + }; + public static WeightRuleInfo GwRule(string condition = "gw>=vw", string factor = "6000") => new() {