using FluentAssertions; using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Orders; using Oms.Application.Services.Orders.Issues; using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Tests.Support; namespace Oms.Tests; /// /// 小包预报应收/应付计费单元测试(与设计文档 §4.4.3.1 续单价示例对齐)。 /// public class ParcelBillingCalculatorTests { private const string Zone = "美西"; private readonly (string PriceVersion, DateTime EffectiveTime) _meta = ParcelBillingTestData.SnapshotMeta(); #region 计重 [Fact] public void CalculateRawBillableWeight_ShouldUseGrossWeightWhenConditionHits() { var forecast = new OrderWeight { Length = 30, Width = 20, Height = 10, ActualWeight = 1.5M }; var rules = new[] { ParcelBillingTestData.GwRule() }; var (vw, raw) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, rules); vw.Should().Be(1M); raw.Should().Be(1.5M); } [Fact] public void ApplyRounding_ShouldCeilToConfiguredUnit() { var ranges = new[] { ParcelBillingTestData.RoundingRange(0, 30, "0.5") }; var rounded = ParcelBillingCalculator.ApplyRounding(1.23M, ranges); rounded.Should().Be(1.5M); } [Fact] public void ApplyRounding_LeftOpenRightClosed_BoundaryGoesToLeftRange() { var ranges = new[] { ParcelBillingTestData.RoundingRange(0, 10, "0.5"), ParcelBillingTestData.RoundingRange(10, 30, "1"), }; ParcelBillingCalculator.ApplyRounding(10M, ranges).Should().Be(10M); ParcelBillingCalculator.ApplyRounding(10.01M, ranges).Should().Be(11M); } [Fact] public void ApplyRounding_LeftOpenRightClosed_StartIsExclusive() { var ranges = new[] { ParcelBillingTestData.RoundingRange(0, 30, "0.5") }; var act = () => ParcelBillingCalculator.ApplyRounding(0M, ranges); act.Should().Throw().WithMessage("*计重进位区间未匹配*"); } #endregion #region 应收(卖价) [Theory] [InlineData(0.8, 18)] [InlineData(1.2, 24)] [InlineData(1.6, 30)] public void CalculateForecastReceivableFees_ContinuePrice_ShouldMatchDesignExamples( double chargeableWeight, decimal expectedFreight) { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem( 1, "freight", ParcelBillingTestData.ContinueFreightDetail())); var fees = ParcelBillingCalculator.CalculateForecastReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, (decimal)chargeableWeight, Zone, "general", null); fees.Should().ContainSingle(); fees[0].Type.Should().Be(OrderFeeTypeCodes.Receivable); fees[0].CostItem.Should().Be("freight"); fees[0].Amount.Should().Be(expectedFreight); fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Forecast); fees[0].PriceNo.Should().Be(_meta.PriceVersion); } [Fact] public void CalculateReceivableFees_ShouldIncludeAllEnabledPriceItems() { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem( 1, "freight", ParcelBillingTestData.ContinueFreightDetail())); var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, new[] { Zone }, "general", null, OrderFeeChargingStageCodes.Forecast); fees.Should().ContainSingle(); fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Forecast); } [Fact] public void CalculateReceivableFees_InboundStage_ShouldSetChargingStage() { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem( 1, "freight", ParcelBillingTestData.ContinueFreightDetail())); var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, new[] { Zone }, "general", null, OrderFeeChargingStageCodes.Inbound); fees.Should().ContainSingle(); fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Inbound); } [Fact] public void CalculateForecastReceivableFees_ShouldIncludeForecastAndFixedPriceItems() { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem( 1, "freight", ParcelBillingTestData.ContinueFreightDetail()), ParcelBillingTestData.ReceivablePriceItem( 1, "操作费", ParcelBillingTestData.FixedPriceDetail(5M))); var fees = ParcelBillingCalculator.CalculateForecastReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, 0.8M, Zone, "general", null); fees.Should().HaveCount(2); fees.Should().Contain(x => x.CostItem == "freight" && x.Amount == 18M); fees.Should().Contain(x => x.CostItem == "操作费" && x.Amount == 5M); var summary = ParcelBillingCalculator.SummarizeFees(fees); summary.Freight.Should().Be(18M); summary.OtherFees.Should().Be(5M); summary.TotalCost.Should().Be(23M); } [Fact] public void CalculateForecastReceivableFees_UnitFormula_ShouldMultiplyChargeableWeight() { var detail = ParcelBillingTestData.UnitPriceDetail(15M); detail.CustomerGroupValues = new Dictionary { ["客户组1"] = 15M }; var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem(1, "freight", detail)); var fees = ParcelBillingCalculator.CalculateForecastReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, 2M, Zone, "general", "客户组1"); fees.Should().ContainSingle(); fees[0].Amount.Should().Be(30M); } [Fact] public void CalculateQuoteReceivableFees_TieredContinueWithTotalBase_Weight08_ShouldBe106() { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem( 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", "普通客户"); fees.Should().ContainSingle(); fees[0].Amount.Should().Be(10.6M); } [Fact] public void CalculateQuoteReceivableFees_ShouldSupportTotalPriceTiers() { 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(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", "普通客户"); fees.Should().ContainSingle(); fees[0].Amount.Should().Be(12M); } [Fact] public void HasEnabledReceivablePriceItems_ShouldDetectAnyEnabledItems() { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem(1, "freight", ParcelBillingTestData.ContinueFreightDetail()), ParcelBillingTestData.ReceivablePriceItem(0, "freight", ParcelBillingTestData.ContinueFreightDetail())); ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshot).Should().BeTrue(); ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshot).Should().BeTrue(); } #endregion #region 应付(买价) [Theory] [InlineData(0.8, 12)] [InlineData(1.2, 16)] [InlineData(1.6, 20)] public void CalculateForecastPayableFees_ContinuePrice_ShouldUseSameEngine( double chargeableWeight, decimal expectedFreight) { var prices = ParcelBillingTestData.BuildPayablePrices( ParcelBillingTestData.PayablePriceItem( 1, "freight", ParcelBillingTestData.ContinueFreightDetail( firstWeight: 1M, firstPrice: 12M, continueUnit: 0.5M, continuePrice: 4M))); var fees = ParcelBillingCalculator.CalculateForecastPayableFees( prices, _meta.PriceVersion, _meta.EffectiveTime, (decimal)chargeableWeight, Zone, "general"); fees.Should().ContainSingle(); fees[0].Type.Should().Be(OrderFeeTypeCodes.Payable); fees[0].Amount.Should().Be(expectedFreight); fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Forecast); } [Fact] public void CalculateForecastPayableFees_ShouldSkipDisabledPriceItems() { var prices = ParcelBillingTestData.BuildPayablePrices( ParcelBillingTestData.PayablePriceItem( 0, "freight", ParcelBillingTestData.ContinueFreightDetail())); var fees = ParcelBillingCalculator.CalculateForecastPayableFees( prices, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general"); fees.Should().BeEmpty(); ParcelBillingCalculator.HasEnabledPayablePriceItems(prices).Should().BeFalse(); } [Fact] public void ReceivableAndPayable_ShouldDifferByTypeButShareZoneMatching() { var detail = ParcelBillingTestData.ContinueFreightDetail(); var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem(1, "freight", detail)); var payablePrices = ParcelBillingTestData.BuildPayablePrices( ParcelBillingTestData.PayablePriceItem(1, "freight", detail)); var receivable = ParcelBillingCalculator.CalculateForecastReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null); var payable = ParcelBillingCalculator.CalculateForecastPayableFees( payablePrices, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general"); receivable[0].Type.Should().Be(OrderFeeTypeCodes.Receivable); payable[0].Type.Should().Be(OrderFeeTypeCodes.Payable); receivable[0].Amount.Should().Be(payable[0].Amount); } #endregion #region 计费条件 / 申报比例 / 去重 [Fact] public void CalculateReceivableFees_BillingCondition_ShouldSkipWhenAdditionalServiceMissing() { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem( 1, "insurance", serialNumber: 1, chargingCondition: "附加服务包含('insurance')", ParcelBillingTestData.FixedPriceDetail(20M))); var context = OrderRuleEvaluationContext.FromOrderOnly(new Order { AdditionalServices = """["remoteFee"]""" }); var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, new[] { Zone }, "general", null, OrderFeeChargingStageCodes.Forecast, conditionContext: context); fees.Should().BeEmpty(); } [Fact] public void CalculateReceivableFees_BillingCondition_ShouldHitWhenAdditionalServiceSelected() { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem( 1, "insurance", serialNumber: 1, chargingCondition: "附加服务包含('insurance')", ParcelBillingTestData.FixedPriceDetail(20M))); var context = OrderRuleEvaluationContext.FromOrderOnly(new Order { AdditionalServices = """["insurance"]""" }); var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, new[] { Zone }, "general", null, OrderFeeChargingStageCodes.Forecast, conditionContext: context); fees.Should().ContainSingle(); fees[0].CostItem.Should().Be("insurance"); fees[0].Amount.Should().Be(20M); } [Fact] public void CalculateReceivableFees_PercentDeclared_ShouldRoundToTwoDecimals() { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem( 1, "insurance", ParcelBillingTestData.PercentDeclaredDetail(1.5M))); var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, 0M, new[] { Zone }, "general", null, OrderFeeChargingStageCodes.Forecast, declaredSum: 1000M, declaredCurrency: "CNY"); fees.Should().ContainSingle(); fees[0].Amount.Should().Be(15M); } [Fact] public void CalculateReceivableFees_PercentFreight_ShouldUseSameBatchFreightBase() { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem( 1, "freight", ParcelBillingTestData.FixedPriceDetail(100M)), ParcelBillingTestData.ReceivablePriceItem( 1, "fuel", ParcelBillingTestData.PercentFreightDetail(10M))); var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, new[] { Zone }, "general", null, OrderFeeChargingStageCodes.Forecast); fees.Should().HaveCount(2); fees.Should().Contain(x => x.CostItem == "freight" && x.Amount == 100M); fees.Should().Contain(x => x.CostItem == "fuel" && x.Amount == 10M); } [Fact] public void CalculateReceivableFees_PercentFreight_ShouldSkipWhenNoFreightInBatch() { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem( 1, "fuel", ParcelBillingTestData.PercentFreightDetail(10M))); var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, new[] { Zone }, "general", null, OrderFeeChargingStageCodes.Forecast); fees.Should().BeEmpty(); } [Fact] public void CalculatePayableFees_PercentFreight_ShouldUseSameBatchFreightBase() { var payablePrices = ParcelBillingTestData.BuildPayablePrices( ParcelBillingTestData.PayablePriceItem( 1, "freight", ParcelBillingTestData.FixedPriceDetail(80M)), ParcelBillingTestData.PayablePriceItem( 1, "fuel", ParcelBillingTestData.PercentFreightDetail(5M))); var fees = ParcelBillingCalculator.CalculatePayableFees( payablePrices, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, new[] { Zone }, "general", OrderFeeChargingStageCodes.Forecast); fees.Should().HaveCount(2); fees.Should().Contain(x => x.CostItem == "freight" && x.Amount == 80M); fees.Should().Contain(x => x.CostItem == "fuel" && x.Amount == 4M); } [Fact] public void NormalizeCalculationFormula_ShouldAliasPercentFreight() { ParcelBillingCalculator.NormalizeCalculationFormula("运费比例").Should().Be("percent_freight"); ParcelBillingCalculator.NormalizeCalculationFormula("percentFreight").Should().Be("percent_freight"); } [Fact] public void CalculateReceivableFees_ShouldDedupeCostItemBySmallestSerialNumber() { var snapshot = ParcelBillingTestData.BuildReceivableSnapshot( ParcelBillingTestData.ReceivablePriceItem( 1, "freight", serialNumber: 20, chargingCondition: null, ParcelBillingTestData.FixedPriceDetail(99M)), ParcelBillingTestData.ReceivablePriceItem( 1, "freight", serialNumber: 5, chargingCondition: null, ParcelBillingTestData.FixedPriceDetail(12M))); var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, new[] { Zone }, "general", null, OrderFeeChargingStageCodes.Forecast); fees.Should().ContainSingle(); 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 }