fix: 查价运费匹配与费用公式展示优化

修复卖价明细英文货物类型无法匹配导致 freight 为 0 的问题;查价返回算法算式、其他费用明细及中文货物属性名;运费列仅汇总 freight 费用项。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-29 19:25:33 +08:00
parent 07fc7fffe7
commit 1c0dce9956
8 changed files with 431 additions and 53 deletions

View File

@ -0,0 +1,27 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 查价结果中的非运费明细(操作费等)。
/// </summary>
public class OrderPriceCheckFeeOutput
{
/// <summary>
/// 费用项目编码(如 handling)。
/// </summary>
public string CostItem { get; set; } = string.Empty;
/// <summary>
/// 费用名称(如 操作费)。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 单项算式片段(如 23)。
/// </summary>
public string FormulaPart { get; set; } = string.Empty;
}

View File

@ -65,6 +65,11 @@ public class OrderPriceCheckOutput
/// </summary>
public decimal OtherFees { get; set; }
/// <summary>
/// 非运费明细(操作费等,含名称与金额)。
/// </summary>
public List<OrderPriceCheckFeeOutput> OtherFeeList { get; set; } = [];
/// <summary>
/// 总成本。
/// </summary>

View File

@ -0,0 +1,15 @@
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 计费试算单行(含展示用算式片段)。
/// </summary>
internal sealed class BillingFeeLine
{
public OrderFee Fee { get; init; } = null!;
public string PriceName { get; init; } = string.Empty;
public string FormulaPart { get; init; } = string.Empty;
}

View File

@ -83,7 +83,7 @@ public partial class OrderService
if (attempt.Success)
{
var quote = attempt.Quote!;
var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees);
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
result.Items.Add(new OrderPriceCheckOutput
{
Id = quote.ProductId,
@ -96,12 +96,13 @@ public partial class OrderService
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
VolumetricWeight = quote.VolumetricWeight,
ChargeableWeight = quote.ChargeableWeight,
CostFormula = costFormula,
Freight = freight,
OtherFees = otherFees,
TotalCost = totalCost,
CostFormula = summary.CostFormula,
Freight = summary.Freight,
OtherFees = summary.OtherFees,
OtherFeeList = summary.OtherFeeList,
TotalCost = summary.TotalCost,
MaxDeliveryDays = product.MaxTimeliness,
TotalCostNum = totalCost,
TotalCostNum = summary.TotalCost,
RulesVersion = quote.RulesVersion,
PriceVersion = quote.PriceVersion,
IsRemote = quote.IsRemote
@ -141,9 +142,7 @@ public partial class OrderService
return codes.Select(code =>
{
var attribute = ParcelBillingCalculator.MapCargoTypeToAttribute(code);
var display = string.IsNullOrWhiteSpace(code)
? ParcelBillingCalculator.GetCargoAttributeLabel(attribute)
: code;
var display = ParcelBillingCalculator.GetCargoAttributeLabel(attribute);
return new PriceCheckCargoTarget(code, display, attribute);
}).ToList();
}
@ -337,7 +336,7 @@ public partial class OrderService
}
var chargeableWeight = forecast.ChargeableWeight ?? 0;
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
snapshotBinding.Price,
snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime,
@ -348,7 +347,7 @@ public partial class OrderService
context.WarehouseId,
context.WarehouseName);
if (fees.Count == 0)
if (feeLines.Count == 0)
{
if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
{
@ -391,7 +390,7 @@ public partial class OrderService
ChargeableWeight = chargeableWeight,
ZoneName = zoneKeys[0],
IsRemote = isRemote,
Fees = fees
FeeLines = feeLines
}
};
}

View File

@ -356,7 +356,7 @@ internal static class ParcelBillingCalculator
CustomerGroup? customerGroup,
int? warehouseId = null,
string? warehouseName = null) =>
CalculateReceivableFees(
CalculateQuoteReceivableFeeLines(
priceSnapshot,
priceVersion,
priceEffectiveTime,
@ -364,9 +364,34 @@ internal static class ParcelBillingCalculator
zoneKeys,
cargoAttribute,
customerGroup,
warehouseId,
warehouseName).Select(x => x.Fee).ToList();
public static List<BillingFeeLine> CalculateQuoteReceivableFeeLines(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup,
int? warehouseId = null,
string? warehouseName = null)
{
var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem(
p.Status, p.CostItem, p.PriceName, p.Currency, p.Details));
return CalculateReceivableFeeLines(
items,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
customerGroup,
OrderFeeChargingStageCodes.Forecast,
warehouseId,
warehouseName);
}
public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
HasEnabledReceivablePriceItems(priceSnapshot);
@ -451,7 +476,7 @@ internal static class ParcelBillingCalculator
var cargoMatchedDetails = zoneMatchedDetails.Where(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return MatchCargoTypeFactor(factors, cargoLabel);
return MatchCargoTypeFactor(factors, cargoAttribute);
}).ToList();
if (cargoMatchedDetails.Count == 0)
@ -581,7 +606,7 @@ internal static class ParcelBillingCalculator
string? warehouseName = null)
{
var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem(
p.Status, p.CostItem, p.Currency, p.Details));
p.Status, p.CostItem, p.PriceName, p.Currency, p.Details));
return CalculateReceivableFeesFromItems(
items,
priceVersion,
@ -691,26 +716,71 @@ internal static class ParcelBillingCalculator
public static (decimal Freight, decimal OtherFees, decimal TotalCost, string CostFormula) SummarizeFees(
IReadOnlyList<OrderFee> fees)
{
if (fees.Count == 0)
var summary = SummarizeQuoteFees(fees.Select(f => new BillingFeeLine
{
return (0, 0, 0, string.Empty);
}
var freight = fees
.Where(x => x.CostItem.Equals("freight", StringComparison.OrdinalIgnoreCase)
|| x.CostItem.Contains("运费", StringComparison.OrdinalIgnoreCase))
.Sum(x => x.Amount);
if (freight <= 0)
{
freight = fees[0].Amount;
}
var total = fees.Sum(x => x.Amount);
var otherFees = Math.Max(0, total - freight);
var formula = string.Join(" + ", fees.Select(x => $"{x.CostItem}:{x.Amount:0.####}"));
return (freight, otherFees, total, formula);
Fee = f,
PriceName = ResolveFeeDisplayName(f.CostItem, null),
FormulaPart = FormatDecimal(f.Amount)
}).ToList());
return (summary.Freight, summary.OtherFees, summary.TotalCost, summary.CostFormula);
}
public static QuoteFeeSummary SummarizeQuoteFees(IReadOnlyList<BillingFeeLine> feeLines)
{
if (feeLines.Count == 0)
{
return new QuoteFeeSummary();
}
var freightLines = feeLines.Where(x => IsFreightCostItem(x.Fee.CostItem)).ToList();
var otherLines = feeLines.Where(x => !IsFreightCostItem(x.Fee.CostItem)).ToList();
var freight = freightLines.Sum(x => x.Fee.Amount);
var otherFees = otherLines.Sum(x => x.Fee.Amount);
var total = feeLines.Sum(x => x.Fee.Amount);
var formulaParts = feeLines
.Select(x => x.FormulaPart)
.Where(part => !string.IsNullOrWhiteSpace(part))
.ToList();
var costFormula = formulaParts.Count > 0 ? string.Join("+", formulaParts) : FormatDecimal(total);
return new QuoteFeeSummary
{
Freight = freight,
OtherFees = otherFees,
TotalCost = total,
CostFormula = costFormula,
OtherFeeList = otherLines.Select(x => new OrderPriceCheckFeeOutput
{
CostItem = x.Fee.CostItem,
Name = ResolveFeeDisplayName(x.Fee.CostItem, x.PriceName),
Amount = x.Fee.Amount,
FormulaPart = x.FormulaPart
}).ToList()
};
}
private static bool IsFreightCostItem(string costItem) =>
costItem.Equals("freight", StringComparison.OrdinalIgnoreCase)
|| costItem.Contains("运费", StringComparison.OrdinalIgnoreCase);
internal static string ResolveFeeDisplayName(string costItem, string? priceName)
{
if (!string.IsNullOrWhiteSpace(priceName))
{
return priceName.Trim();
}
return costItem.ToLowerInvariant() switch
{
"freight" => "运费",
"handling" => "操作费",
_ => costItem
};
}
internal static string FormatDecimal(decimal value) =>
value.ToString("0.####", CultureInfo.InvariantCulture);
public static OrderWeight BuildForecastWeight(ParcelQuoteContext context) =>
new()
{
@ -723,6 +793,29 @@ internal static class ParcelBillingCalculator
};
private static List<OrderFee> CalculateReceivableFeesFromItems(
IEnumerable<ReceivableBillingPriceItem> priceItems,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup,
string chargingStage,
int? warehouseId = null,
string? warehouseName = null) =>
CalculateReceivableFeeLines(
priceItems,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
customerGroup,
chargingStage,
warehouseId,
warehouseName).Select(x => x.Fee).ToList();
private static List<BillingFeeLine> CalculateReceivableFeeLines(
IEnumerable<ReceivableBillingPriceItem> priceItems,
string priceVersion,
DateTime priceEffectiveTime,
@ -734,7 +827,7 @@ internal static class ParcelBillingCalculator
int? warehouseId = null,
string? warehouseName = null)
{
var fees = new List<OrderFee>();
var fees = new List<BillingFeeLine>();
foreach (var priceItem in priceItems.Where(x => x.Status == 1))
{
var detail = MatchPriceDetail(
@ -758,15 +851,29 @@ internal static class ParcelBillingCalculator
customerGroup,
warehouseId,
warehouseName);
fees.Add(new OrderFee
var formulaPart = BuildDetailFormulaExpression(
priceItem.Details,
detail,
zoneKeys,
cargoAttribute,
chargeableWeight,
customerGroup,
warehouseId,
warehouseName);
fees.Add(new BillingFeeLine
{
Type = OrderFeeTypeCodes.Receivable,
CostItem = priceItem.CostItem,
Amount = amount,
Currency = priceItem.Currency,
PriceNo = priceVersion,
PriceEffectiveTime = priceEffectiveTime,
ChargingStage = chargingStage
Fee = new OrderFee
{
Type = OrderFeeTypeCodes.Receivable,
CostItem = priceItem.CostItem,
Amount = amount,
Currency = priceItem.Currency,
PriceNo = priceVersion,
PriceEffectiveTime = priceEffectiveTime,
ChargingStage = chargingStage
},
PriceName = priceItem.PriceName,
FormulaPart = formulaPart
});
}
@ -827,6 +934,7 @@ internal static class ParcelBillingCalculator
private readonly record struct ReceivableBillingPriceItem(
byte Status,
string CostItem,
string PriceName,
string Currency,
List<ChannelPriceDetailOutput> Details);
@ -844,7 +952,6 @@ internal static class ParcelBillingCalculator
int? warehouseId = null,
string? warehouseName = null)
{
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
ChannelPriceDetailOutput? bestMatch = null;
var bestIntervalWidth = decimal.MaxValue;
@ -856,7 +963,7 @@ internal static class ParcelBillingCalculator
continue;
}
if (!MatchCargoTypeFactor(factors, cargoLabel))
if (!MatchCargoTypeFactor(factors, cargoAttribute))
{
continue;
}
@ -960,15 +1067,30 @@ internal static class ParcelBillingCalculator
private static bool TryGetWeightFactorText(Dictionary<string, string> factors, out string weightText) =>
TryGetFactorValue(factors, out weightText, "重量", "weight") && !string.IsNullOrWhiteSpace(weightText);
private static bool MatchCargoTypeFactor(Dictionary<string, string> factors, string cargoLabel)
/// <summary>
/// 货物类型因子匹配:明细可能存中文标签(普货)或英文 code(general),查价侧统一为 cargoAttribute。
/// </summary>
private static bool MatchCargoTypeFactor(Dictionary<string, string> factors, string cargoAttribute)
{
if (!TryGetFactorValue(factors, out var actual, "货物类型", "cargo") || string.IsNullOrWhiteSpace(actual))
{
return true;
}
return string.Equals(actual.Trim(), cargoLabel, StringComparison.OrdinalIgnoreCase)
|| string.Equals(actual.Trim(), cargoLabel, StringComparison.Ordinal);
var trimmed = actual.Trim();
var expectedLabel = GetCargoAttributeLabel(cargoAttribute);
if (string.Equals(trimmed, expectedLabel, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (string.Equals(trimmed, cargoAttribute, StringComparison.OrdinalIgnoreCase))
{
return true;
}
var actualAttribute = MapCargoTypeToAttribute(trimmed);
return string.Equals(actualAttribute, cargoAttribute, StringComparison.OrdinalIgnoreCase);
}
private static bool MatchWarehouseFactor(
@ -1062,7 +1184,6 @@ internal static class ParcelBillingCalculator
int? warehouseId = null,
string? warehouseName = null)
{
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
return NormalizeCalculationFormula(detail.CalculationFormula) switch
{
"unit" => CalculateUnitPrice(detail, chargeableWeight, customerGroup),
@ -1071,7 +1192,7 @@ internal static class ParcelBillingCalculator
allDetails,
detail,
zoneKeys,
cargoLabel,
cargoAttribute,
chargeableWeight,
customerGroup,
warehouseId,
@ -1080,6 +1201,102 @@ internal static class ParcelBillingCalculator
};
}
internal static string BuildDetailFormulaExpression(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
decimal chargeableWeight,
CustomerGroup? customerGroup,
int? warehouseId = null,
string? warehouseName = null)
{
return NormalizeCalculationFormula(detail.CalculationFormula) switch
{
"unit" => BuildUnitFormulaExpression(detail, chargeableWeight, customerGroup),
"total" => FormatDecimal(
ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0),
"continue" => BuildContinueFormulaExpression(
allDetails,
detail,
zoneKeys,
cargoAttribute,
chargeableWeight,
customerGroup,
warehouseId,
warehouseName),
_ => FormatDecimal(detail.CalculatedValue ?? 0)
};
}
private static string BuildUnitFormulaExpression(
ChannelPriceDetailOutput detail,
decimal chargeableWeight,
CustomerGroup? customerGroup)
{
var unitPrice = ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0;
return $"{FormatDecimal(unitPrice)}*{FormatDecimal(chargeableWeight)}";
}
private static string BuildContinueFormulaExpression(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
decimal chargeableWeight,
CustomerGroup? customerGroup,
int? warehouseId = null,
string? warehouseName = null)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (TryGetWeightFactorText(factors, out var weightText)
&& TryParseWeightInterval(weightText, out var rangeStart, out _)
&& TryFindAdjacentTotalBasePrice(
allDetails,
zoneKeys,
cargoAttribute,
rangeStart,
customerGroup,
warehouseId,
warehouseName,
out var basePrice))
{
if (chargeableWeight <= rangeStart)
{
return FormatDecimal(basePrice);
}
var continueUnitPrice = ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0;
return
$"{FormatDecimal(basePrice)}+({FormatDecimal(chargeableWeight)}-{FormatDecimal(rangeStart)})*{FormatDecimal(continueUnitPrice)}";
}
var continuePricing = TryReadContinuePricing(factors, detail);
var firstWeight = continuePricing?.FirstWeight ?? 1M;
var firstPrice = continuePricing?.FirstPrice ?? detail.CalculatedValue ?? 0;
var continueUnit = continuePricing?.ContinueUnit ?? 0.5M;
var continuePrice = continuePricing?.ContinuePrice ?? firstPrice;
if (customerGroup.HasValue)
{
var groupPrice = ResolveCustomerGroupPrice(detail, customerGroup);
if (groupPrice.HasValue)
{
firstPrice = groupPrice.Value;
}
}
if (chargeableWeight <= firstWeight)
{
return FormatDecimal(firstPrice);
}
var overflow = chargeableWeight - firstWeight;
var continueUnits = Math.Ceiling(overflow / continueUnit);
return
$"{FormatDecimal(firstPrice)}+{FormatDecimal(continueUnits)}*{FormatDecimal(continuePrice)}";
}
/// <summary>
/// 单价:计费重 × 单价。
/// </summary>
@ -1099,7 +1316,7 @@ internal static class ParcelBillingCalculator
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList<string> zoneKeys,
string cargoLabel,
string cargoAttribute,
decimal chargeableWeight,
CustomerGroup? customerGroup,
int? warehouseId = null,
@ -1111,7 +1328,7 @@ internal static class ParcelBillingCalculator
&& TryFindAdjacentTotalBasePrice(
allDetails,
zoneKeys,
cargoLabel,
cargoAttribute,
rangeStart,
customerGroup,
warehouseId,
@ -1139,7 +1356,7 @@ internal static class ParcelBillingCalculator
private static bool TryFindAdjacentTotalBasePrice(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
IReadOnlyList<string> zoneKeys,
string cargoLabel,
string cargoAttribute,
decimal continueRangeStart,
CustomerGroup? customerGroup,
int? warehouseId,
@ -1151,7 +1368,7 @@ internal static class ParcelBillingCalculator
{
var factors = ParseFactorDictionary(candidate.FactorValuesJson);
if (!ZoneMatches(factors, zoneKeys)
|| !MatchCargoTypeFactor(factors, cargoLabel)
|| !MatchCargoTypeFactor(factors, cargoAttribute)
|| !MatchWarehouseFactor(factors, warehouseId, warehouseName))
{
continue;

View File

@ -25,5 +25,5 @@ internal sealed class ParcelQuoteResult
public bool IsRemote { get; init; }
public IReadOnlyList<OrderFee> Fees { get; init; } = [];
public IReadOnlyList<BillingFeeLine> FeeLines { get; init; } = [];
}

View File

@ -0,0 +1,19 @@
using Oms.Application.Dtos.Orders;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 查价费用汇总(含算式与其他费用明细)。
/// </summary>
internal sealed class QuoteFeeSummary
{
public decimal Freight { get; init; }
public decimal OtherFees { get; init; }
public decimal TotalCost { get; init; }
public string CostFormula { get; init; } = string.Empty;
public List<OrderPriceCheckFeeOutput> OtherFeeList { get; init; } = [];
}

View File

@ -62,6 +62,102 @@ public class PriceCheckSnapshotVerificationTests
CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = normalPrice }
};
private static ChannelPriceDetailOutput DetailWithCargoAndWarehouse(
int id,
int priceId,
string formula,
string zone,
string weight,
string cargoType,
string warehouseId,
decimal normalPrice) =>
new()
{
Id = id,
PriceId = priceId,
CalculationFormula = formula,
CalculatedValue = 0,
FactorValuesJson = new Dictionary<string, string>
{
["分区"] = zone,
["重量"] = weight,
["货物类型"] = cargoType,
["仓库"] = warehouseId
},
CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = normalPrice }
};
[Fact]
public void EnglishCargoTypeFactor_ShouldMatchCargoAttributeForFreight()
{
var snapshot = new SalesProductPricePublishSnapshotDto
{
SalesProductId = 1,
Prices =
[
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{
Id = 1,
Status = 1,
CostItem = "freight",
Details =
[
DetailWithCargoAndWarehouse(61, 1, "total", "3", "(0-0.5]", "general", "2", 10),
DetailWithCargoAndWarehouse(62, 1, "total", "3", "(0-0.5]", "electronics", "2", 11),
]
}),
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{
Id = 2,
Status = 1,
CostItem = "handling",
Details = [Detail(44, 2, "total", "3", "(0-10]", 23)]
})
]
};
var generalFees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
snapshot,
"PRICE-TEST",
DateTime.Now,
0.214M,
["3"],
CargoAttributeCodes.General,
CustomerGroup.Normal,
warehouseId: 2);
generalFees.Should().HaveCount(2);
generalFees.Single(x => x.CostItem == "freight").Amount.Should().Be(10M);
generalFees.Single(x => x.CostItem == "handling").Amount.Should().Be(23M);
var summary = ParcelBillingCalculator.SummarizeQuoteFees(
ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
snapshot,
"PRICE-TEST",
DateTime.Now,
0.214M,
["3"],
CargoAttributeCodes.General,
CustomerGroup.Normal,
warehouseId: 2));
summary.CostFormula.Should().Be("10+23");
summary.Freight.Should().Be(10M);
summary.OtherFees.Should().Be(23M);
summary.OtherFeeList.Should().ContainSingle(x => x.Name == "操作费" && x.Amount == 23M);
var electronicsFees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
snapshot,
"PRICE-TEST",
DateTime.Now,
0.214M,
["3"],
CargoAttributeCodes.Electronics,
CustomerGroup.Normal,
warehouseId: 2);
electronicsFees.Single(x => x.CostItem == "freight").Amount.Should().Be(11M);
}
[Fact]
public void TryParseWeightInterval_SingleValue_ShouldBeLeftOpenRightClosedFromZero()
{