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> /// </summary>
public decimal OtherFees { get; set; } public decimal OtherFees { get; set; }
/// <summary>
/// 非运费明细(操作费等,含名称与金额)。
/// </summary>
public List<OrderPriceCheckFeeOutput> OtherFeeList { get; set; } = [];
/// <summary> /// <summary>
/// 总成本。 /// 总成本。
/// </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) if (attempt.Success)
{ {
var quote = attempt.Quote!; var quote = attempt.Quote!;
var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees); var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
result.Items.Add(new OrderPriceCheckOutput result.Items.Add(new OrderPriceCheckOutput
{ {
Id = quote.ProductId, Id = quote.ProductId,
@ -96,12 +96,13 @@ public partial class OrderService
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}", DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
VolumetricWeight = quote.VolumetricWeight, VolumetricWeight = quote.VolumetricWeight,
ChargeableWeight = quote.ChargeableWeight, ChargeableWeight = quote.ChargeableWeight,
CostFormula = costFormula, CostFormula = summary.CostFormula,
Freight = freight, Freight = summary.Freight,
OtherFees = otherFees, OtherFees = summary.OtherFees,
TotalCost = totalCost, OtherFeeList = summary.OtherFeeList,
TotalCost = summary.TotalCost,
MaxDeliveryDays = product.MaxTimeliness, MaxDeliveryDays = product.MaxTimeliness,
TotalCostNum = totalCost, TotalCostNum = summary.TotalCost,
RulesVersion = quote.RulesVersion, RulesVersion = quote.RulesVersion,
PriceVersion = quote.PriceVersion, PriceVersion = quote.PriceVersion,
IsRemote = quote.IsRemote IsRemote = quote.IsRemote
@ -141,9 +142,7 @@ public partial class OrderService
return codes.Select(code => return codes.Select(code =>
{ {
var attribute = ParcelBillingCalculator.MapCargoTypeToAttribute(code); var attribute = ParcelBillingCalculator.MapCargoTypeToAttribute(code);
var display = string.IsNullOrWhiteSpace(code) var display = ParcelBillingCalculator.GetCargoAttributeLabel(attribute);
? ParcelBillingCalculator.GetCargoAttributeLabel(attribute)
: code;
return new PriceCheckCargoTarget(code, display, attribute); return new PriceCheckCargoTarget(code, display, attribute);
}).ToList(); }).ToList();
} }
@ -337,7 +336,7 @@ public partial class OrderService
} }
var chargeableWeight = forecast.ChargeableWeight ?? 0; var chargeableWeight = forecast.ChargeableWeight ?? 0;
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees( var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
snapshotBinding.Price, snapshotBinding.Price,
snapshotBinding.PriceVersion, snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime, snapshotBinding.PriceEffectiveTime,
@ -348,7 +347,7 @@ public partial class OrderService
context.WarehouseId, context.WarehouseId,
context.WarehouseName); context.WarehouseName);
if (fees.Count == 0) if (feeLines.Count == 0)
{ {
if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price)) if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
{ {
@ -391,7 +390,7 @@ public partial class OrderService
ChargeableWeight = chargeableWeight, ChargeableWeight = chargeableWeight,
ZoneName = zoneKeys[0], ZoneName = zoneKeys[0],
IsRemote = isRemote, IsRemote = isRemote,
Fees = fees FeeLines = feeLines
} }
}; };
} }

View File

@ -356,7 +356,7 @@ internal static class ParcelBillingCalculator
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
int? warehouseId = null, int? warehouseId = null,
string? warehouseName = null) => string? warehouseName = null) =>
CalculateReceivableFees( CalculateQuoteReceivableFeeLines(
priceSnapshot, priceSnapshot,
priceVersion, priceVersion,
priceEffectiveTime, priceEffectiveTime,
@ -364,9 +364,34 @@ internal static class ParcelBillingCalculator
zoneKeys, zoneKeys,
cargoAttribute, cargoAttribute,
customerGroup, 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, OrderFeeChargingStageCodes.Forecast,
warehouseId, warehouseId,
warehouseName); warehouseName);
}
public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
HasEnabledReceivablePriceItems(priceSnapshot); HasEnabledReceivablePriceItems(priceSnapshot);
@ -451,7 +476,7 @@ internal static class ParcelBillingCalculator
var cargoMatchedDetails = zoneMatchedDetails.Where(detail => var cargoMatchedDetails = zoneMatchedDetails.Where(detail =>
{ {
var factors = ParseFactorDictionary(detail.FactorValuesJson); var factors = ParseFactorDictionary(detail.FactorValuesJson);
return MatchCargoTypeFactor(factors, cargoLabel); return MatchCargoTypeFactor(factors, cargoAttribute);
}).ToList(); }).ToList();
if (cargoMatchedDetails.Count == 0) if (cargoMatchedDetails.Count == 0)
@ -581,7 +606,7 @@ internal static class ParcelBillingCalculator
string? warehouseName = null) string? warehouseName = null)
{ {
var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem( 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( return CalculateReceivableFeesFromItems(
items, items,
priceVersion, priceVersion,
@ -691,26 +716,71 @@ internal static class ParcelBillingCalculator
public static (decimal Freight, decimal OtherFees, decimal TotalCost, string CostFormula) SummarizeFees( public static (decimal Freight, decimal OtherFees, decimal TotalCost, string CostFormula) SummarizeFees(
IReadOnlyList<OrderFee> fees) IReadOnlyList<OrderFee> fees)
{ {
if (fees.Count == 0) var summary = SummarizeQuoteFees(fees.Select(f => new BillingFeeLine
{ {
return (0, 0, 0, string.Empty); Fee = f,
} PriceName = ResolveFeeDisplayName(f.CostItem, null),
FormulaPart = FormatDecimal(f.Amount)
var freight = fees }).ToList());
.Where(x => x.CostItem.Equals("freight", StringComparison.OrdinalIgnoreCase) return (summary.Freight, summary.OtherFees, summary.TotalCost, summary.CostFormula);
|| 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);
} }
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) => public static OrderWeight BuildForecastWeight(ParcelQuoteContext context) =>
new() new()
{ {
@ -723,6 +793,29 @@ internal static class ParcelBillingCalculator
}; };
private static List<OrderFee> CalculateReceivableFeesFromItems( 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, IEnumerable<ReceivableBillingPriceItem> priceItems,
string priceVersion, string priceVersion,
DateTime priceEffectiveTime, DateTime priceEffectiveTime,
@ -734,7 +827,7 @@ internal static class ParcelBillingCalculator
int? warehouseId = null, int? warehouseId = null,
string? warehouseName = null) string? warehouseName = null)
{ {
var fees = new List<OrderFee>(); var fees = new List<BillingFeeLine>();
foreach (var priceItem in priceItems.Where(x => x.Status == 1)) foreach (var priceItem in priceItems.Where(x => x.Status == 1))
{ {
var detail = MatchPriceDetail( var detail = MatchPriceDetail(
@ -758,15 +851,29 @@ internal static class ParcelBillingCalculator
customerGroup, customerGroup,
warehouseId, warehouseId,
warehouseName); warehouseName);
fees.Add(new OrderFee var formulaPart = BuildDetailFormulaExpression(
priceItem.Details,
detail,
zoneKeys,
cargoAttribute,
chargeableWeight,
customerGroup,
warehouseId,
warehouseName);
fees.Add(new BillingFeeLine
{ {
Type = OrderFeeTypeCodes.Receivable, Fee = new OrderFee
CostItem = priceItem.CostItem, {
Amount = amount, Type = OrderFeeTypeCodes.Receivable,
Currency = priceItem.Currency, CostItem = priceItem.CostItem,
PriceNo = priceVersion, Amount = amount,
PriceEffectiveTime = priceEffectiveTime, Currency = priceItem.Currency,
ChargingStage = chargingStage PriceNo = priceVersion,
PriceEffectiveTime = priceEffectiveTime,
ChargingStage = chargingStage
},
PriceName = priceItem.PriceName,
FormulaPart = formulaPart
}); });
} }
@ -827,6 +934,7 @@ internal static class ParcelBillingCalculator
private readonly record struct ReceivableBillingPriceItem( private readonly record struct ReceivableBillingPriceItem(
byte Status, byte Status,
string CostItem, string CostItem,
string PriceName,
string Currency, string Currency,
List<ChannelPriceDetailOutput> Details); List<ChannelPriceDetailOutput> Details);
@ -844,7 +952,6 @@ internal static class ParcelBillingCalculator
int? warehouseId = null, int? warehouseId = null,
string? warehouseName = null) string? warehouseName = null)
{ {
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
ChannelPriceDetailOutput? bestMatch = null; ChannelPriceDetailOutput? bestMatch = null;
var bestIntervalWidth = decimal.MaxValue; var bestIntervalWidth = decimal.MaxValue;
@ -856,7 +963,7 @@ internal static class ParcelBillingCalculator
continue; continue;
} }
if (!MatchCargoTypeFactor(factors, cargoLabel)) if (!MatchCargoTypeFactor(factors, cargoAttribute))
{ {
continue; continue;
} }
@ -960,15 +1067,30 @@ internal static class ParcelBillingCalculator
private static bool TryGetWeightFactorText(Dictionary<string, string> factors, out string weightText) => private static bool TryGetWeightFactorText(Dictionary<string, string> factors, out string weightText) =>
TryGetFactorValue(factors, out weightText, "重量", "weight") && !string.IsNullOrWhiteSpace(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)) if (!TryGetFactorValue(factors, out var actual, "货物类型", "cargo") || string.IsNullOrWhiteSpace(actual))
{ {
return true; return true;
} }
return string.Equals(actual.Trim(), cargoLabel, StringComparison.OrdinalIgnoreCase) var trimmed = actual.Trim();
|| string.Equals(actual.Trim(), cargoLabel, StringComparison.Ordinal); 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( private static bool MatchWarehouseFactor(
@ -1062,7 +1184,6 @@ internal static class ParcelBillingCalculator
int? warehouseId = null, int? warehouseId = null,
string? warehouseName = null) string? warehouseName = null)
{ {
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
return NormalizeCalculationFormula(detail.CalculationFormula) switch return NormalizeCalculationFormula(detail.CalculationFormula) switch
{ {
"unit" => CalculateUnitPrice(detail, chargeableWeight, customerGroup), "unit" => CalculateUnitPrice(detail, chargeableWeight, customerGroup),
@ -1071,7 +1192,7 @@ internal static class ParcelBillingCalculator
allDetails, allDetails,
detail, detail,
zoneKeys, zoneKeys,
cargoLabel, cargoAttribute,
chargeableWeight, chargeableWeight,
customerGroup, customerGroup,
warehouseId, 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>
/// 单价:计费重 × 单价。 /// 单价:计费重 × 单价。
/// </summary> /// </summary>
@ -1099,7 +1316,7 @@ internal static class ParcelBillingCalculator
IReadOnlyList<ChannelPriceDetailOutput> allDetails, IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail, ChannelPriceDetailOutput detail,
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
string cargoLabel, string cargoAttribute,
decimal chargeableWeight, decimal chargeableWeight,
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
int? warehouseId = null, int? warehouseId = null,
@ -1111,7 +1328,7 @@ internal static class ParcelBillingCalculator
&& TryFindAdjacentTotalBasePrice( && TryFindAdjacentTotalBasePrice(
allDetails, allDetails,
zoneKeys, zoneKeys,
cargoLabel, cargoAttribute,
rangeStart, rangeStart,
customerGroup, customerGroup,
warehouseId, warehouseId,
@ -1139,7 +1356,7 @@ internal static class ParcelBillingCalculator
private static bool TryFindAdjacentTotalBasePrice( private static bool TryFindAdjacentTotalBasePrice(
IReadOnlyList<ChannelPriceDetailOutput> allDetails, IReadOnlyList<ChannelPriceDetailOutput> allDetails,
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
string cargoLabel, string cargoAttribute,
decimal continueRangeStart, decimal continueRangeStart,
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
int? warehouseId, int? warehouseId,
@ -1151,7 +1368,7 @@ internal static class ParcelBillingCalculator
{ {
var factors = ParseFactorDictionary(candidate.FactorValuesJson); var factors = ParseFactorDictionary(candidate.FactorValuesJson);
if (!ZoneMatches(factors, zoneKeys) if (!ZoneMatches(factors, zoneKeys)
|| !MatchCargoTypeFactor(factors, cargoLabel) || !MatchCargoTypeFactor(factors, cargoAttribute)
|| !MatchWarehouseFactor(factors, warehouseId, warehouseName)) || !MatchWarehouseFactor(factors, warehouseId, warehouseName))
{ {
continue; continue;

View File

@ -25,5 +25,5 @@ internal sealed class ParcelQuoteResult
public bool IsRemote { get; init; } 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 } 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] [Fact]
public void TryParseWeightInterval_SingleValue_ShouldBeLeftOpenRightClosedFromZero() public void TryParseWeightInterval_SingleValue_ShouldBeLeftOpenRightClosedFromZero()
{ {