fix: 查价运费匹配与费用公式展示优化
修复卖价明细英文货物类型无法匹配导致 freight 为 0 的问题;查价返回算法算式、其他费用明细及中文货物属性名;运费列仅汇总 freight 费用项。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
07fc7fffe7
commit
1c0dce9956
27
src/Oms.Application/Dtos/Orders/OrderPriceCheckFeeOutput.cs
Normal file
27
src/Oms.Application/Dtos/Orders/OrderPriceCheckFeeOutput.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
15
src/Oms.Application/Services/Orders/BillingFeeLine.cs
Normal file
15
src/Oms.Application/Services/Orders/BillingFeeLine.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}).ToList());
|
||||||
|
return (summary.Freight, summary.OtherFees, summary.TotalCost, summary.CostFormula);
|
||||||
}
|
}
|
||||||
|
|
||||||
var freight = fees
|
public static QuoteFeeSummary SummarizeQuoteFees(IReadOnlyList<BillingFeeLine> feeLines)
|
||||||
.Where(x => x.CostItem.Equals("freight", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| x.CostItem.Contains("运费", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.Sum(x => x.Amount);
|
|
||||||
if (freight <= 0)
|
|
||||||
{
|
{
|
||||||
freight = fees[0].Amount;
|
if (feeLines.Count == 0)
|
||||||
|
{
|
||||||
|
return new QuoteFeeSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
var total = fees.Sum(x => x.Amount);
|
var freightLines = feeLines.Where(x => IsFreightCostItem(x.Fee.CostItem)).ToList();
|
||||||
var otherFees = Math.Max(0, total - freight);
|
var otherLines = feeLines.Where(x => !IsFreightCostItem(x.Fee.CostItem)).ToList();
|
||||||
var formula = string.Join(" + ", fees.Select(x => $"{x.CostItem}:{x.Amount:0.####}"));
|
var freight = freightLines.Sum(x => x.Fee.Amount);
|
||||||
return (freight, otherFees, total, formula);
|
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,7 +851,18 @@ 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
|
||||||
|
{
|
||||||
|
Fee = new OrderFee
|
||||||
{
|
{
|
||||||
Type = OrderFeeTypeCodes.Receivable,
|
Type = OrderFeeTypeCodes.Receivable,
|
||||||
CostItem = priceItem.CostItem,
|
CostItem = priceItem.CostItem,
|
||||||
@ -767,6 +871,9 @@ internal static class ParcelBillingCalculator
|
|||||||
PriceNo = priceVersion,
|
PriceNo = priceVersion,
|
||||||
PriceEffectiveTime = priceEffectiveTime,
|
PriceEffectiveTime = priceEffectiveTime,
|
||||||
ChargingStage = chargingStage
|
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;
|
||||||
|
|||||||
@ -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; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/Oms.Application/Services/Orders/QuoteFeeSummary.cs
Normal file
19
src/Oms.Application/Services/Orders/QuoteFeeSummary.cs
Normal 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; } = [];
|
||||||
|
}
|
||||||
@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user