diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs index 5fac4a4..d4d6fdc 100644 --- a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs +++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs @@ -47,6 +47,66 @@ public class SalesProductRulesPublishSnapshotDto /// 分配的服务商列表。 /// public List Providers { get; set; } = []; + + /// + /// 黑名单分区 ID(发布时固化)。 + /// + public int? BlacklistAreaId { get; set; } + + /// + /// 黑名单分区名称。 + /// + public string? BlacklistAreaName { get; set; } + + /// + /// 黑名单分区明细,发布时从 t_kpi_area_info 固化。 + /// + public List BlacklistAreaInfos { get; set; } = []; + + /// + /// 偏远分区 ID(发布时固化)。 + /// + public int? RemoteAreaId { get; set; } + + /// + /// 偏远分区名称。 + /// + public string? RemoteAreaName { get; set; } + + /// + /// 偏远分区明细,发布时从 t_kpi_area_info 固化。 + /// + public List RemoteAreaInfos { get; set; } = []; + + /// + /// 计重规则 ID(发布时固化)。 + /// + public int? WeightRuleId { get; set; } + + /// + /// 计重规则名称。 + /// + public string? WeightRuleName { get; set; } + + /// + /// 计重规则明细,发布时从 t_weight_rule_info 固化。 + /// + public List WeightRuleInfos { get; set; } = []; + + /// + /// 计重进位 ID(发布时固化)。 + /// + public int? WeightRoundingId { get; set; } + + /// + /// 计重进位名称。 + /// + public string? WeightRoundingName { get; set; } + + /// + /// 计重进位区间,发布时从 t_weight_rounding_info 固化。 + /// + public List WeightRoundingInfos { get; set; } = []; } /// diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 5c6ba2f..948094b 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -174,7 +174,7 @@ public partial class SalesProductService } else { - snapshot = new SalesProductRulesPublishSnapshot + var rulesSnapshot = new SalesProductRulesPublishSnapshot { Product = new SalesProductSaveInput { @@ -197,6 +197,13 @@ public partial class SalesProductService }, Providers = (await GetAllocationCoreAsync(salesProductId)).Providers }; + await RulesSnapshotHelper.ApplyRulesToSnapshotAsync( + rulesSnapshot, + product, + KpiAreaRepository, + WeightRuleRepository, + WeightRoundingRepository); + snapshot = rulesSnapshot; } // Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。 @@ -434,10 +441,7 @@ public partial class SalesProductService { } - private sealed class SalesProductRulesPublishSnapshot + private sealed class SalesProductRulesPublishSnapshot : SalesProductRulesPublishSnapshotDto { - public SalesProductSaveInput Product { get; set; } = new(); - - public List Providers { get; set; } = []; } } diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index 2fe6b3d..c55cfb5 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -21,6 +21,8 @@ public partial class SalesProductService : ApplicationService private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService(); private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService(); private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService(); + private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService(); + private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService(); /// /// 查询销售产品列表。 diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 6e55317..72998d8 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -57,6 +57,7 @@ public partial class OrderService if (await IsBlacklistHitAsync( product, + snapshotBinding.Rules, country.CountryCode, input.Receiver?.Province, input.Receiver?.City, @@ -76,13 +77,14 @@ public partial class OrderService { order.IsRemote = await IsRemoteHitAsync( product, + snapshotBinding.Rules, country.CountryCode, input.Receiver?.Province, input.Receiver?.City, input.Receiver?.ZipCode) ? (byte)1 : (byte)0; var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast); - await ApplyChargeableWeightAsync(product, forecast); + await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast); SyncWeightRows(weights, forecast); await OrderRepository.ReplaceWeightsAsync(order.Id, weights); @@ -287,9 +289,9 @@ public partial class OrderService }; } - private Task BuildLiveRulesSnapshotAsync(SalesProduct product) + private async Task BuildLiveRulesSnapshotAsync(SalesProduct product) { - return Task.FromResult(new SalesProductRulesPublishSnapshotDto + var snapshot = new SalesProductRulesPublishSnapshotDto { Product = new SalesProductSaveInput { @@ -304,7 +306,14 @@ public partial class OrderService IsActive = product.IsActive, Scheme = product.Scheme } - }); + }; + await RulesSnapshotHelper.ApplyRulesToSnapshotAsync( + snapshot, + product, + KpiAreaRepository, + WeightRuleRepository, + WeightRoundingRepository); + return snapshot; } private async Task BuildLivePriceSnapshotAsync(SalesProduct product) @@ -372,17 +381,29 @@ public partial class OrderService private async Task IsBlacklistHitAsync( SalesProduct product, + SalesProductRulesPublishSnapshotDto? rulesSnapshot, string countryCode, string? province, string? city, string? zipCode) { - if (!product.BlacklistAreaId.HasValue || product.BlacklistAreaId <= 0) + var areaId = ResolveEffectiveBlacklistAreaId(product, rulesSnapshot); + if (!areaId.HasValue) { return false; } - var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.BlacklistAreaId.Value); + if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 }) + { + return RulesSnapshotHelper.IsAreaHitFromSnapshot( + rulesSnapshot.BlacklistAreaInfos, + countryCode, + province, + city, + zipCode); + } + + var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( rule, countryCode, @@ -393,17 +414,29 @@ public partial class OrderService private async Task IsRemoteHitAsync( SalesProduct product, + SalesProductRulesPublishSnapshotDto? rulesSnapshot, string countryCode, string? province, string? city, string? zipCode) { - if (!product.RemoteAreaId.HasValue || product.RemoteAreaId <= 0) + var areaId = ResolveEffectiveRemoteAreaId(product, rulesSnapshot); + if (!areaId.HasValue) { return false; } - var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.RemoteAreaId.Value); + if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 }) + { + return RulesSnapshotHelper.IsAreaHitFromSnapshot( + rulesSnapshot.RemoteAreaInfos, + countryCode, + province, + city, + zipCode); + } + + var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( rule, countryCode, @@ -412,6 +445,50 @@ public partial class OrderService zipCode)); } + private static int? ResolveEffectiveBlacklistAreaId( + SalesProduct product, + SalesProductRulesPublishSnapshotDto? rulesSnapshot) + { + if (product.BlacklistAreaId is > 0) + { + return product.BlacklistAreaId; + } + + if (rulesSnapshot?.BlacklistAreaId is > 0) + { + return rulesSnapshot.BlacklistAreaId; + } + + if (rulesSnapshot?.Product?.BlacklistAreaId is > 0) + { + return rulesSnapshot.Product.BlacklistAreaId; + } + + return null; + } + + private static int? ResolveEffectiveRemoteAreaId( + SalesProduct product, + SalesProductRulesPublishSnapshotDto? rulesSnapshot) + { + if (product.RemoteAreaId is > 0) + { + return product.RemoteAreaId; + } + + if (rulesSnapshot?.RemoteAreaId is > 0) + { + return rulesSnapshot.RemoteAreaId; + } + + if (rulesSnapshot?.Product?.RemoteAreaId is > 0) + { + return rulesSnapshot.Product.RemoteAreaId; + } + + return null; + } + private static int? ResolveEffectivePriceAreaId( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, @@ -482,37 +559,60 @@ public partial class OrderService return keys.Count > 0 ? keys : null; } - private async Task ApplyChargeableWeightAsync(SalesProduct product, OrderWeight forecast) + private async Task ApplyChargeableWeightAsync( + SalesProduct product, + SalesProductRulesPublishSnapshotDto? rulesSnapshot, + OrderWeight forecast) { try { - if (!product.WeightRuleId.HasValue || product.WeightRuleId <= 0) + var weightRuleId = ResolveEffectiveWeightRuleId(product, rulesSnapshot); + if (!weightRuleId.HasValue) { throw new BusinessException("未配置计重规则"); } - var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRuleId.Value); - if (weightRule == null) + IReadOnlyList ruleInfos; + if (rulesSnapshot?.WeightRuleInfos is { Count: > 0 }) { - throw new BusinessException("未配置计重规则"); + ruleInfos = RulesSnapshotHelper.ToWeightRuleEntities(rulesSnapshot.WeightRuleInfos); + } + else + { + var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value); + if (weightRule == null) + { + throw new BusinessException("未配置计重规则"); + } + + ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value); } - var ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value); var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos); forecast.VolumetricWeight = volumetricWeight; - if (!product.WeightRoundingId.HasValue || product.WeightRoundingId <= 0) + var weightRoundingId = ResolveEffectiveWeightRoundingId(product, rulesSnapshot); + if (!weightRoundingId.HasValue) { throw new BusinessException("未配置计重进位"); } - var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRoundingId.Value); - if (rounding == null) + IReadOnlyList ranges; + if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }) { - throw new BusinessException("未配置计重进位"); + ranges = RulesSnapshotHelper.ToWeightRoundingEntities(rulesSnapshot.WeightRoundingInfos); + } + else + { + var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId.Value); + if (rounding == null) + { + throw new BusinessException("未配置计重进位"); + } + + ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value); } - var ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value); forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); } catch (BusinessException) @@ -525,6 +625,50 @@ public partial class OrderService } } + private static int? ResolveEffectiveWeightRuleId( + SalesProduct product, + SalesProductRulesPublishSnapshotDto? rulesSnapshot) + { + if (product.WeightRuleId is > 0) + { + return product.WeightRuleId; + } + + if (rulesSnapshot?.WeightRuleId is > 0) + { + return rulesSnapshot.WeightRuleId; + } + + if (rulesSnapshot?.Product?.WeightRuleId is > 0) + { + return rulesSnapshot.Product.WeightRuleId; + } + + return null; + } + + private static int? ResolveEffectiveWeightRoundingId( + SalesProduct product, + SalesProductRulesPublishSnapshotDto? rulesSnapshot) + { + if (product.WeightRoundingId is > 0) + { + return product.WeightRoundingId; + } + + if (rulesSnapshot?.WeightRoundingId is > 0) + { + return rulesSnapshot.WeightRoundingId; + } + + if (rulesSnapshot?.Product?.WeightRoundingId is > 0) + { + return rulesSnapshot.Product.WeightRoundingId; + } + + return null; + } + private static List BuildInitialWeightRows(int orderId, OrderParcelCreateInput input) { var cubic = ParcelBillingCalculator.CalculateCubic(input.Length!.Value, input.Width!.Value, input.Height!.Value); diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index f1740da..125e0d8 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -196,6 +196,7 @@ public partial class OrderService if (await IsBlacklistHitAsync( product, + snapshotBinding.Rules, context.CountryCode, context.Province, context.City, @@ -210,7 +211,7 @@ public partial class OrderService var forecast = ParcelBillingCalculator.BuildForecastWeight(context); try { - await ApplyChargeableWeightAsync(product, forecast); + await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast); } catch (BusinessException ex) { @@ -281,6 +282,7 @@ public partial class OrderService var isRemote = await IsRemoteHitAsync( product, + snapshotBinding.Rules, context.CountryCode, context.Province, context.City, diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index f0434b7..4f24e7c 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -631,7 +631,13 @@ internal static class ParcelBillingCalculator continue; } - var amount = CalculateDetailAmount(detail, chargeableWeight, customerGroup); + var amount = CalculateDetailAmount( + priceItem.Details, + detail, + zoneKeys, + cargoAttribute, + chargeableWeight, + customerGroup); fees.Add(new OrderFee { Type = feeType, @@ -835,16 +841,119 @@ internal static class ParcelBillingCalculator } private static decimal CalculateDetailAmount( + IReadOnlyList allDetails, + ChannelPriceDetailOutput detail, + IReadOnlyList zoneKeys, + string cargoAttribute, + decimal chargeableWeight, + CustomerGroup? customerGroup) + { + var cargoLabel = GetCargoAttributeLabel(cargoAttribute); + return NormalizeCalculationFormula(detail.CalculationFormula) switch + { + "unit" => CalculateUnitPrice(detail, chargeableWeight, customerGroup), + "total" => ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0, + "continue" => CalculateContinueAmount( + allDetails, + detail, + zoneKeys, + cargoLabel, + chargeableWeight, + customerGroup), + _ => detail.CalculatedValue ?? 0 + }; + } + + /// + /// 单价:计费重 × 单价。 + /// + private static decimal CalculateUnitPrice( ChannelPriceDetailOutput detail, decimal chargeableWeight, CustomerGroup? customerGroup) { - return NormalizeCalculationFormula(detail.CalculationFormula) switch + var unitPrice = ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0; + return Math.Round(chargeableWeight * unitPrice, 4, MidpointRounding.AwayFromZero); + } + + /// + /// 续单价:优先「下档总价 + (计费重-区间起点)×续单价」;否则回退单行 continuePricing(首重/续重进位)。 + /// + private static decimal CalculateContinueAmount( + IReadOnlyList allDetails, + ChannelPriceDetailOutput detail, + IReadOnlyList zoneKeys, + string cargoLabel, + decimal chargeableWeight, + CustomerGroup? customerGroup) + { + var factors = ParseFactorDictionary(detail.FactorValuesJson); + if (TryGetWeightFactorText(factors, out var weightText) + && TryParseWeightInterval(weightText, out var rangeStart, out _) + && TryFindAdjacentTotalBasePrice( + allDetails, + zoneKeys, + cargoLabel, + rangeStart, + customerGroup, + out var basePrice)) { - "unit" or "total" => ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0, - "continue" => CalculateContinuePrice(detail, chargeableWeight, customerGroup), - _ => detail.CalculatedValue ?? 0 - }; + var continueUnitPrice = ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0; + if (chargeableWeight <= rangeStart) + { + return Math.Round(basePrice, 4, MidpointRounding.AwayFromZero); + } + + return Math.Round( + basePrice + (chargeableWeight - rangeStart) * continueUnitPrice, + 4, + MidpointRounding.AwayFromZero); + } + + return CalculateContinuePrice(detail, chargeableWeight, customerGroup); + } + + /// + /// 查找与续单价区间左端点衔接的下一档总价(如 (0,0.5] 总价 10 对应 (0.5,1] 续单价)。 + /// + private static bool TryFindAdjacentTotalBasePrice( + IReadOnlyList allDetails, + IReadOnlyList zoneKeys, + string cargoLabel, + decimal continueRangeStart, + CustomerGroup? customerGroup, + out decimal basePrice) + { + basePrice = 0; + foreach (var candidate in allDetails) + { + var factors = ParseFactorDictionary(candidate.FactorValuesJson); + if (!ZoneMatches(factors, zoneKeys) || !MatchCargoTypeFactor(factors, cargoLabel)) + { + continue; + } + + if (NormalizeCalculationFormula(candidate.CalculationFormula) != "total") + { + continue; + } + + if (!TryGetWeightFactorText(factors, out var weightText) + || !TryParseWeightInterval(weightText, out _, out var rangeEnd)) + { + continue; + } + + if (rangeEnd != continueRangeStart) + { + continue; + } + + basePrice = ResolveCustomerGroupPrice(candidate, customerGroup) ?? candidate.CalculatedValue ?? 0; + return true; + } + + return false; } /// diff --git a/src/Oms.Application/Shared/RulesSnapshotHelper.cs b/src/Oms.Application/Shared/RulesSnapshotHelper.cs new file mode 100644 index 0000000..6ea474a --- /dev/null +++ b/src/Oms.Application/Shared/RulesSnapshotHelper.cs @@ -0,0 +1,220 @@ +using Oms.Application.Dtos.Channels; +using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Orders; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.ChannelsAggregate.Repositories; + +namespace Oms.Application.Shared; + +/// +/// 将黑名单/偏远/计重/进位规则固化进销售产品规则发布快照。 +/// +public static class RulesSnapshotHelper +{ + /// + /// 按产品配置加载 KPI 分区、计重规则、进位区间写入规则快照。 + /// + public static async Task ApplyRulesToSnapshotAsync( + SalesProductRulesPublishSnapshotDto snapshot, + SalesProduct product, + IKpiAreaRepository kpiAreaRepository, + IWeightRuleRepository weightRuleRepository, + IWeightRoundingRepository weightRoundingRepository, + CancellationToken cancellationToken = default) + { + await ApplyKpiAreaToSnapshotAsync( + snapshot, + product.BlacklistAreaId, + kpiAreaRepository, + (s, areaId, areaName, infos) => + { + s.BlacklistAreaId = areaId; + s.BlacklistAreaName = areaName; + s.BlacklistAreaInfos = infos; + }, + cancellationToken); + + await ApplyKpiAreaToSnapshotAsync( + snapshot, + product.RemoteAreaId, + kpiAreaRepository, + (s, areaId, areaName, infos) => + { + s.RemoteAreaId = areaId; + s.RemoteAreaName = areaName; + s.RemoteAreaInfos = infos; + }, + cancellationToken); + + await ApplyWeightRuleToSnapshotAsync(snapshot, product, weightRuleRepository, cancellationToken); + await ApplyWeightRoundingToSnapshotAsync(snapshot, product, weightRoundingRepository, cancellationToken); + } + + /// + /// 从快照内嵌黑名单/偏远明细匹配地址(不查库)。 + /// + public static bool IsAreaHitFromSnapshot( + IReadOnlyList? infos, + string countryCode, + string? province, + string? city, + string? zipCode) + { + if (infos == null || infos.Count == 0) + { + return false; + } + + return infos.Any(rule => ParcelBillingCalculator.IsAreaMatched( + rule, + countryCode, + province, + city, + zipCode)); + } + + /// + /// 将快照内嵌计重明细转为实体供计算器使用。 + /// + public static List ToWeightRuleEntities(IReadOnlyList infos) => + infos.Select(MapWeightRuleInfo).ToList(); + + /// + /// 将快照内嵌进位区间转为实体供计算器使用。 + /// + public static List ToWeightRoundingEntities(IReadOnlyList infos) => + infos.Select(MapWeightRoundingInfo).ToList(); + + private static async Task ApplyKpiAreaToSnapshotAsync( + SalesProductRulesPublishSnapshotDto snapshot, + int? areaId, + IKpiAreaRepository kpiAreaRepository, + Action> assign, + CancellationToken cancellationToken) + { + if (!areaId.HasValue || areaId.Value <= 0) + { + assign(snapshot, null, null, []); + return; + } + + var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId.Value, cancellationToken); + var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value, cancellationToken); + assign( + snapshot, + areaId, + area?.Name, + infos.Select(MapKpiAreaInfo).ToList()); + } + + private static async Task ApplyWeightRuleToSnapshotAsync( + SalesProductRulesPublishSnapshotDto snapshot, + SalesProduct product, + IWeightRuleRepository weightRuleRepository, + CancellationToken cancellationToken) + { + if (!product.WeightRuleId.HasValue || product.WeightRuleId.Value <= 0) + { + snapshot.WeightRuleId = null; + snapshot.WeightRuleName = null; + snapshot.WeightRuleInfos = []; + return; + } + + snapshot.WeightRuleId = product.WeightRuleId; + var rule = await weightRuleRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRuleId.Value, cancellationToken); + snapshot.WeightRuleName = rule?.Name; + var infos = await weightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value, cancellationToken); + snapshot.WeightRuleInfos = infos.Select(MapWeightRuleInfoOutput).ToList(); + } + + private static async Task ApplyWeightRoundingToSnapshotAsync( + SalesProductRulesPublishSnapshotDto snapshot, + SalesProduct product, + IWeightRoundingRepository weightRoundingRepository, + CancellationToken cancellationToken) + { + if (!product.WeightRoundingId.HasValue || product.WeightRoundingId.Value <= 0) + { + snapshot.WeightRoundingId = null; + snapshot.WeightRoundingName = null; + snapshot.WeightRoundingInfos = []; + return; + } + + snapshot.WeightRoundingId = product.WeightRoundingId; + var rounding = await weightRoundingRepository.FirstOrDefaultAsync( + x => x.Id == product.WeightRoundingId.Value, + cancellationToken); + snapshot.WeightRoundingName = rounding?.Name; + var infos = await weightRoundingRepository.GetAllInfosByRoundingIdAsync( + product.WeightRoundingId.Value, + cancellationToken); + snapshot.WeightRoundingInfos = infos.Select(MapWeightRoundingInfoOutput).ToList(); + } + + private static KpiAreaInfoOutput MapKpiAreaInfo(KpiAreaInfo entity) => + new() + { + Id = entity.Id, + AreaId = entity.AreaId, + CountryCode = entity.CountryCode, + CountryName = entity.CountryName, + Province = entity.Province, + City = entity.City, + LeftPostcode = entity.LeftPostcode, + RightPostcode = entity.RightPostcode, + CreateTime = entity.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + }; + + private static WeightRuleInfoOutput MapWeightRuleInfoOutput(WeightRuleInfo entity) => + new() + { + Id = entity.Id, + RuleId = entity.RuleId, + Client = entity.Client, + Condition = entity.Condition, + BillableWeight = entity.BillableWeight, + VolumetricFactor = entity.VolumetricFactor, + CreateTime = entity.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + }; + + private static WeightRoundingInfoOutput MapWeightRoundingInfoOutput(WeightRoundingInfo entity) => + new() + { + Id = entity.Id, + RoundingId = entity.RoundingId, + StartValue = entity.StartValue, + EndValue = entity.EndValue, + Public = entity.Public, + CustomerGroup1 = entity.CustomerGroup1, + CustomerGroup2 = entity.CustomerGroup2, + CreateTime = entity.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + }; + + private static WeightRuleInfo MapWeightRuleInfo(WeightRuleInfoOutput output) => + new() + { + Id = output.Id, + RuleId = output.RuleId, + Client = output.Client, + Condition = output.Condition, + BillableWeight = output.BillableWeight, + VolumetricFactor = output.VolumetricFactor + }; + + private static WeightRoundingInfo MapWeightRoundingInfo(WeightRoundingInfoOutput output) => + new() + { + Id = output.Id, + RoundingId = output.RoundingId, + StartValue = output.StartValue, + EndValue = output.EndValue, + Public = output.Public, + CustomerGroup1 = output.CustomerGroup1, + CustomerGroup2 = output.CustomerGroup2 + }; +} diff --git a/src/Oms.Tests/ParcelBillingCalculatorTests.cs b/src/Oms.Tests/ParcelBillingCalculatorTests.cs index f8fa838..f9721e2 100644 --- a/src/Oms.Tests/ParcelBillingCalculatorTests.cs +++ b/src/Oms.Tests/ParcelBillingCalculatorTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Orders; using Oms.Domain.CustomersAggregate.Enums; @@ -143,9 +144,9 @@ public class ParcelBillingCalculatorTests } [Fact] - public void CalculateForecastReceivableFees_ShouldApplyCustomerGroupPrice() + public void CalculateForecastReceivableFees_UnitFormula_ShouldMultiplyChargeableWeight() { - var detail = ParcelBillingTestData.UnitPriceDetail(20M); + var detail = ParcelBillingTestData.UnitPriceDetail(15M); detail.CustomerGroupValues = new Dictionary { ["客户组1"] = 15M }; var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( @@ -161,7 +162,41 @@ public class ParcelBillingCalculatorTests CustomerGroup.Vip1); fees.Should().ContainSingle(); - fees[0].Amount.Should().Be(15M); + fees[0].Amount.Should().Be(30M); + } + + [Fact] + public void CalculateQuoteReceivableFees_TieredContinueWithTotalBase_Weight08_ShouldBe106() + { + var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( + ParcelBillingTestData.ReceivablePriceItem( + "outbound", + 1, + "freight", + ParcelBillingTestData.TotalTierDetail("3", 0.5M, 10M, weightInterval: "(0-0.5]"), + new ChannelPriceDetailOutput + { + CalculationFormula = "continue", + CalculatedValue = 0, + FactorValuesJson = new Dictionary + { + ["分区"] = "3", + ["重量"] = "(0.5-1]" + }, + CustomerGroupValues = new Dictionary { ["普通客户"] = 2M } + })); + + var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees( + snapshot, + _meta.PriceVersion, + _meta.EffectiveTime, + 0.8M, + new[] { "3" }, + "general", + CustomerGroup.Normal); + + fees.Should().ContainSingle(); + fees[0].Amount.Should().Be(10.6M); } [Fact] diff --git a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs index 866197a..244976c 100644 --- a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs +++ b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs @@ -25,10 +25,10 @@ public class PriceCheckSnapshotVerificationTests ChargingTime = "outbound", Details = [ - Detail(24, 1, "unit", "3", "0,0.5", 11), - Detail(25, 1, "unit", "3", "0.5,1", 12), - Detail(26, 1, "unit", "3", "1,1.5", 13), - Detail(27, 1, "unit", "3", "1.5,2", 14), + Detail(24, 1, "total", "3", "0,0.5", 11), + Detail(25, 1, "total", "3", "0.5,1", 12), + Detail(26, 1, "total", "3", "1,1.5", 13), + Detail(27, 1, "total", "3", "1.5,2", 14), ] }, new SalesProductPriceListOutput @@ -97,8 +97,8 @@ public class PriceCheckSnapshotVerificationTests CostItem = "freight", Details = [ - Detail(34, 1, "unit", "3", "(0-0.5]", 11), - Detail(35, 1, "unit", "3", "(0.5-1]", 12), + Detail(34, 1, "total", "3", "(0-0.5]", 11), + Detail(35, 1, "total", "3", "(0.5-1]", 12), ] }, new SalesProductPriceListOutput diff --git a/src/Oms.Tests/RulesSnapshotVerificationTests.cs b/src/Oms.Tests/RulesSnapshotVerificationTests.cs new file mode 100644 index 0000000..5bc9206 --- /dev/null +++ b/src/Oms.Tests/RulesSnapshotVerificationTests.cs @@ -0,0 +1,131 @@ +using FluentAssertions; +using Oms.Application.Dtos.Channels; +using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Tests; + +/// +/// 验证规则快照内嵌黑名单/偏远/计重/进位在内存中可匹配。 +/// +public class RulesSnapshotVerificationTests +{ + private static SalesProductRulesPublishSnapshotDto BuildRulesSnapshot() => new() + { + BlacklistAreaId = 10, + BlacklistAreaInfos = + [ + new KpiAreaInfoOutput + { + Id = 1, + AreaId = 10, + CountryCode = "US", + LeftPostcode = "90000", + RightPostcode = "90010" + } + ], + RemoteAreaId = 20, + RemoteAreaInfos = + [ + new KpiAreaInfoOutput + { + Id = 2, + AreaId = 20, + CountryCode = "US", + LeftPostcode = "06000", + RightPostcode = "06999" + } + ], + WeightRuleId = 1, + WeightRuleInfos = + [ + new WeightRuleInfoOutput + { + Id = 1, + RuleId = 1, + VolumetricFactor = "6000", + Condition = "gw>vw", + BillableWeight = "gw" + } + ], + WeightRoundingId = 1, + WeightRoundingInfos = + [ + new WeightRoundingInfoOutput + { + Id = 1, + RoundingId = 1, + StartValue = 0, + EndValue = 30, + Public = "0.5" + } + ] + }; + + [Fact] + public void IsAreaHitFromSnapshot_BlacklistZip90005_ShouldHit() + { + var snapshot = BuildRulesSnapshot(); + RulesSnapshotHelper.IsAreaHitFromSnapshot( + snapshot.BlacklistAreaInfos, + "US", + null, + null, + "90005") + .Should() + .BeTrue(); + } + + [Fact] + public void IsAreaHitFromSnapshot_BlacklistZip06000_ShouldMiss() + { + var snapshot = BuildRulesSnapshot(); + RulesSnapshotHelper.IsAreaHitFromSnapshot( + snapshot.BlacklistAreaInfos, + "US", + null, + null, + "06000") + .Should() + .BeFalse(); + } + + [Fact] + public void IsAreaHitFromSnapshot_RemoteZip06000_ShouldHit() + { + var snapshot = BuildRulesSnapshot(); + RulesSnapshotHelper.IsAreaHitFromSnapshot( + snapshot.RemoteAreaInfos, + "US", + null, + null, + "06000") + .Should() + .BeTrue(); + } + + [Fact] + public void SnapshotWeightRule_10x10x10_Actual05_ShouldRoundTo05Chargeable() + { + var snapshot = BuildRulesSnapshot(); + var forecast = new OrderWeight + { + Type = OrderWeightTypeCodes.Forecast, + Length = 10, + Width = 10, + Height = 10, + ActualWeight = 0.5M + }; + + var ruleInfos = RulesSnapshotHelper.ToWeightRuleEntities(snapshot.WeightRuleInfos); + var (_, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos); + var ranges = RulesSnapshotHelper.ToWeightRoundingEntities(snapshot.WeightRoundingInfos); + var chargeable = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); + + rawWeight.Should().Be(0.5M); + chargeable.Should().Be(0.5M); + } +} diff --git a/src/Oms.Tests/Support/ParcelBillingTestData.cs b/src/Oms.Tests/Support/ParcelBillingTestData.cs index 048c350..57dffe2 100644 --- a/src/Oms.Tests/Support/ParcelBillingTestData.cs +++ b/src/Oms.Tests/Support/ParcelBillingTestData.cs @@ -95,7 +95,8 @@ internal static class ParcelBillingTestData public static ChannelPriceDetailOutput TotalTierDetail( string zone, decimal weightTier, - decimal normalCustomerPrice) => + decimal normalCustomerPrice, + string? weightInterval = null) => new() { CalculationFormula = "total", @@ -103,7 +104,7 @@ internal static class ParcelBillingTestData FactorValuesJson = new Dictionary { ["分区"] = zone, - ["重量"] = weightTier.ToString(CultureInfo.InvariantCulture) + ["重量"] = weightInterval ?? weightTier.ToString(CultureInfo.InvariantCulture) }, CustomerGroupValues = new Dictionary { ["普通客户"] = normalCustomerPrice } };