Drop sell/buy price charging-time config; write receivable fees after forecast or receive chargeable weight, hook inbound billing, fix order list status filter, and add DB migration. Co-authored-by: Cursor <cursoragent@cursor.com>
357 lines
11 KiB
C#
357 lines
11 KiB
C#
using FluentAssertions;
|
||
using Oms.Application.Dtos.Channels;
|
||
using Oms.Application.Dtos.Orders;
|
||
using Oms.Application.Services.Orders;
|
||
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);
|
||
}
|
||
|
||
#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",
|
||
CustomerGroup.Vip1);
|
||
|
||
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",
|
||
CustomerGroup.Normal);
|
||
|
||
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",
|
||
CustomerGroup.Normal);
|
||
|
||
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",
|
||
null);
|
||
|
||
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",
|
||
null);
|
||
|
||
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", null);
|
||
|
||
receivable[0].Type.Should().Be(OrderFeeTypeCodes.Receivable);
|
||
payable[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
|
||
receivable[0].Amount.Should().Be(payable[0].Amount);
|
||
}
|
||
|
||
#endregion
|
||
}
|