admin/src/Oms.Tests/ParcelBillingCalculatorTests.cs
zzh b748aa4000
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
fix: 收紧分区 range、进位相接与仅国家估算口径
进位区间改为左开右闭且禁止空洞,邮编 range 按数字/字母数字比较,查价仅国家时不再串用别国 zone。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 17:35:18 +08:00

643 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// 小包预报应收/应付计费单元测试(与设计文档 §4.4.3.1 续单价示例对齐)。
/// </summary>
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<InvalidOperationException>().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<string, decimal> { ["客户组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<string, string>
{
["分区"] = "3",
["重量"] = "(0.5-1]"
},
CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = 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<string, decimal> { ["普通客户"] = 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<string>());
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
}