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
}