admin/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
zzh e2443ed6f8 fix: restore country+weight price-check estimates for postcode zones.
When rules require postal codes, still quote by same-country snapshot zones as estimated; keep blocking countries not configured at all.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 11:14:12 +08:00

1718 lines
59 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 System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.Shared;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 小包预报:计重、分区匹配、卖价算价。
/// </summary>
internal static class ParcelBillingCalculator
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public static decimal CalculateCubic(decimal length, decimal width, decimal height) =>
Math.Round(length * width * height / 1_000_000M, 6, MidpointRounding.AwayFromZero);
public static (decimal VolumetricWeight, decimal RawBillableWeight) CalculateRawBillableWeight(
OrderWeight forecast,
IReadOnlyList<WeightRuleInfo> rules)
{
var length = forecast.Length ?? 0;
var width = forecast.Width ?? 0;
var height = forecast.Height ?? 0;
var gw = forecast.ActualWeight ?? 0;
if (gw <= 0 || length <= 0 || width <= 0 || height <= 0)
{
throw new InvalidOperationException("件重尺数据不完整");
}
if (rules.Count == 0)
{
throw new InvalidOperationException("计重规则明细为空");
}
foreach (var rule in rules.OrderBy(x => x.Id))
{
if (!decimal.TryParse(rule.VolumetricFactor, NumberStyles.Any, CultureInfo.InvariantCulture, out var factor) || factor <= 0)
{
continue;
}
var vw = Math.Round(length * width * height / factor, 3, MidpointRounding.AwayFromZero);
if (!EvaluateCondition(rule.Condition, gw, vw))
{
continue;
}
var raw = string.Equals(rule.BillableWeight, "gw", StringComparison.OrdinalIgnoreCase) ? gw : vw;
return (vw, raw);
}
throw new InvalidOperationException("计重规则条件均未命中");
}
public static decimal ApplyRounding(decimal rawWeight, IReadOnlyList<WeightRoundingInfo> ranges)
{
var hit = ranges
.OrderBy(x => x.Id)
.FirstOrDefault(x => rawWeight >= x.StartValue && rawWeight <= x.EndValue);
if (hit == null)
{
throw new InvalidOperationException("计重进位区间未匹配");
}
if (!decimal.TryParse(hit.Public, NumberStyles.Any, CultureInfo.InvariantCulture, out var unit) || unit <= 0)
{
throw new InvalidOperationException("计重进位值无效");
}
var units = Math.Ceiling(rawWeight / unit);
return Math.Round(units * unit, 3, MidpointRounding.AwayFromZero);
}
public static bool IsAreaMatched(
KpiAreaInfo rule,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (!string.IsNullOrWhiteSpace(rule.CountryCode)
&& !string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(rule.Province)
&& !string.Equals(rule.Province, province, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(rule.City)
&& !string.Equals(rule.City, city, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(rule.LeftPostcode) || !string.IsNullOrWhiteSpace(rule.RightPostcode))
{
if (string.IsNullOrWhiteSpace(zipCode))
{
return false;
}
if (!IsPostcodeInRange(zipCode, rule.LeftPostcode, rule.RightPostcode))
{
return false;
}
}
return true;
}
public static bool IsAreaMatched(
KpiAreaInfoOutput rule,
string countryCode,
string? province,
string? city,
string? zipCode) =>
IsAreaMatched(MapInfoOutputToEntity(rule), countryCode, province, city, zipCode);
/// <summary>
/// 从卖价快照内固化的分区规则解析分区键(不查库)。
/// </summary>
public static IReadOnlyList<string>? TryResolveZoneKeysFromPriceSnapshot(
SalesProductPricePublishSnapshotDto? priceSnapshot,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (priceSnapshot?.PriceAreaInfos == null || priceSnapshot.PriceAreaInfos.Count == 0)
{
return null;
}
var matched = priceSnapshot.PriceAreaInfos.FirstOrDefault(rule =>
KpiAreaSnapshotMapper.IsRuleMatched(rule, countryCode, province, city, zipCode));
if (matched == null)
{
return null;
}
var keys = BuildPriceZoneKeys(matched, priceSnapshot.PriceAreaName, priceSnapshot.PriceAreaId);
return keys.Count > 0 ? keys : null;
}
/// <summary>
/// 查价仅填国家时:若快照已配置该目的国分区(即使规则带邮编区间),
/// 则返回卖价明细中的分区键供估算报价;未配置该国家时返回空(不跨国家兜底)。
/// </summary>
public static IReadOnlyList<string> CollectCountryEstimateZoneKeys(
SalesProductPricePublishSnapshotDto? priceSnapshot,
string countryCode)
{
if (priceSnapshot?.PriceAreaInfos == null
|| priceSnapshot.PriceAreaInfos.Count == 0
|| string.IsNullOrWhiteSpace(countryCode))
{
return [];
}
var countryConfigured = priceSnapshot.PriceAreaInfos.Any(rule =>
string.IsNullOrWhiteSpace(rule.CountryCode)
|| string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase));
if (!countryConfigured)
{
return [];
}
return CollectDistinctDetailZoneKeys(priceSnapshot);
}
public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area)
{
if (matched == null)
{
return null;
}
if (!string.IsNullOrWhiteSpace(matched.Province))
{
return matched.Province;
}
if (!string.IsNullOrWhiteSpace(matched.City))
{
return matched.City;
}
if (!string.IsNullOrWhiteSpace(matched.CountryName))
{
return matched.CountryName;
}
return area?.Name;
}
public static IReadOnlyList<string> BuildPriceZoneKeys(KpiAreaInfo? matched, KpiArea? area)
{
var keys = new List<string>();
if (matched != null)
{
var name = ResolveZoneName(matched, area);
if (!string.IsNullOrWhiteSpace(name))
{
keys.Add(name);
}
keys.Add(matched.Id.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(matched.Province))
{
keys.Add(matched.Province);
}
if (!string.IsNullOrWhiteSpace(matched.CountryName))
{
keys.Add(matched.CountryName);
}
}
if (area != null)
{
keys.Add(area.Id.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(area.Name))
{
keys.Add(area.Name);
}
if (!string.IsNullOrWhiteSpace(area.SystemName))
{
keys.Add(area.SystemName);
}
}
return keys.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
public static IReadOnlyList<string> BuildPriceZoneKeys(
KpiAreaSnapshotRuleDto matched,
string? areaName,
int? areaId)
{
var keys = new List<string>();
var name = ResolveZoneName(matched, areaName);
if (!string.IsNullOrWhiteSpace(name))
{
keys.Add(name);
}
keys.Add(matched.Id.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(matched.Province))
{
keys.Add(matched.Province);
}
if (!string.IsNullOrWhiteSpace(matched.CountryCode))
{
keys.Add(matched.CountryCode);
}
if (areaId is > 0)
{
keys.Add(areaId.Value.ToString(CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(areaName))
{
keys.Add(areaName);
}
return keys.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
public static string? ResolveZoneName(KpiAreaSnapshotRuleDto matched, string? areaName)
{
if (!string.IsNullOrWhiteSpace(matched.Province))
{
return matched.Province;
}
if (!string.IsNullOrWhiteSpace(matched.City))
{
return matched.City;
}
if (!string.IsNullOrWhiteSpace(matched.CountryCode))
{
return matched.CountryCode;
}
return areaName;
}
public static IReadOnlyList<string> BuildPriceZoneKeys(
KpiAreaInfoOutput matched,
string? areaName,
int? areaId)
{
var keys = new List<string>();
var name = ResolveZoneName(matched, areaName);
if (!string.IsNullOrWhiteSpace(name))
{
keys.Add(name);
}
keys.Add(matched.Id.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(matched.Province))
{
keys.Add(matched.Province);
}
if (!string.IsNullOrWhiteSpace(matched.CountryName))
{
keys.Add(matched.CountryName);
}
if (areaId is > 0)
{
keys.Add(areaId.Value.ToString(CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(areaName))
{
keys.Add(areaName);
}
return keys.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
public static string? ResolveZoneName(KpiAreaInfoOutput matched, string? areaName)
{
if (!string.IsNullOrWhiteSpace(matched.Province))
{
return matched.Province;
}
if (!string.IsNullOrWhiteSpace(matched.City))
{
return matched.City;
}
if (!string.IsNullOrWhiteSpace(matched.CountryName))
{
return matched.CountryName;
}
return areaName;
}
private static KpiAreaInfo MapInfoOutputToEntity(KpiAreaInfoOutput rule) =>
new()
{
Id = rule.Id,
AreaId = rule.AreaId,
CountryCode = rule.CountryCode,
CountryName = rule.CountryName,
Province = rule.Province,
City = rule.City,
LeftPostcode = rule.LeftPostcode,
RightPostcode = rule.RightPostcode
};
/// <summary>
/// 卖价/买价明细匹配共用上下文(分区、货物属性、计费重、仓库)。
/// </summary>
internal readonly record struct PriceDetailMatchContext(
IReadOnlyList<string> ZoneKeys,
string CargoAttribute,
decimal ChargeableWeight,
int? WarehouseId = null,
string? WarehouseName = null);
public static List<OrderFee> CalculateQuoteReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
string? priceTierKey,
int? warehouseId = null,
string? warehouseName = null) =>
CalculateQuoteReceivableFeeLines(
priceSnapshot,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
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,
string? priceTierKey,
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,
priceTierKey,
OrderFeeChargingStageCodes.Forecast,
warehouseId,
warehouseName);
}
public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
HasEnabledReceivablePriceItems(priceSnapshot);
public static bool HasEnabledReceivablePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
priceSnapshot.Prices.Any(x => EnableStatusRules.IsEnabled(x.Status));
/// <summary>
/// 收集卖价快照中所有启用价格项的明细分区键(去重)。
/// </summary>
public static IReadOnlyList<string> CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot)
{
var zones = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var price in priceSnapshot.Prices.Where(x => EnableStatusRules.IsEnabled(x.Status)))
{
foreach (var detail in price.Details ?? [])
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (TryGetZoneFactorText(factors, out var zone))
{
zones.Add(zone.Trim());
}
}
}
return zones.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
}
/// <summary>
/// 查价未命中时,按价格项输出具体原因(分区/货物类型/重量区间等)。
/// </summary>
public static string ExplainQuotePriceMiss(
SalesProductPricePublishSnapshotDto priceSnapshot,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
string? priceTierKey,
int? warehouseId = null,
string? warehouseName = null)
{
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
var warehouseLabel = FormatWarehouseLabel(warehouseId, warehouseName);
var enabledPrices = priceSnapshot.Prices.Where(x => EnableStatusRules.IsEnabled(x.Status)).ToList();
if (enabledPrices.Count == 0)
{
return "卖价快照中无启用的价格项";
}
var reasons = new List<string>();
foreach (var price in enabledPrices)
{
var label = !string.IsNullOrWhiteSpace(price.PriceName) ? price.PriceName! : price.CostItem;
var details = price.Details ?? [];
if (details.Count == 0)
{
reasons.Add($"【{label}】价格项Id={price.Id}:无明细行");
continue;
}
var zoneMatchedDetails = details.Where(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return ZoneMatches(factors, zoneKeys);
}).ToList();
if (zoneMatchedDetails.Count == 0)
{
var detailZones = details
.Select(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return TryGetZoneFactorText(factors, out var zone) ? zone.Trim() : null;
})
.Where(zone => !string.IsNullOrWhiteSpace(zone))
.Distinct(StringComparer.OrdinalIgnoreCase);
reasons.Add(
$"【{label}】价格项Id={price.Id}:分区不匹配,明细分区=[{string.Join(", ", detailZones)}]," +
$"解析分区键=[{string.Join(", ", zoneKeys)}]");
continue;
}
var cargoMatchedDetails = zoneMatchedDetails.Where(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return MatchCargoTypeFactor(factors, cargoAttribute);
}).ToList();
if (cargoMatchedDetails.Count == 0)
{
var requiredCargo = zoneMatchedDetails
.Select(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return TryGetFactorValue(factors, out var cargo, "货物类型", "cargo") ? cargo.Trim() : null;
})
.Where(cargo => !string.IsNullOrWhiteSpace(cargo))
.Distinct(StringComparer.OrdinalIgnoreCase);
var requiredText = requiredCargo.Any() ? string.Join(", ", requiredCargo) : "(明细未限定货物类型)";
reasons.Add($"【{label}】价格项Id={price.Id}:货物类型不匹配,当前={cargoLabel},明细要求=[{requiredText}]");
continue;
}
var warehouseMatchedDetails = cargoMatchedDetails.Where(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return MatchWarehouseFactor(factors, warehouseId, warehouseName);
}).ToList();
if (warehouseMatchedDetails.Count == 0)
{
var requiredWarehouse = cargoMatchedDetails
.Select(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return TryGetFactorValue(factors, out var wh, "仓库", "warehouse") ? wh.Trim() : null;
})
.Where(wh => !string.IsNullOrWhiteSpace(wh))
.Distinct(StringComparer.OrdinalIgnoreCase);
var requiredText = requiredWarehouse.Any() ? string.Join(", ", requiredWarehouse) : "(明细未限定仓库)";
reasons.Add(
$"【{label}】价格项Id={price.Id}:仓库不匹配,当前={warehouseLabel},明细要求=[{requiredText}]");
continue;
}
var weightMatched = false;
var intervals = new List<string>();
foreach (var detail in warehouseMatchedDetails)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (!TryGetWeightFactorText(factors, out var weightText))
{
weightMatched = true;
break;
}
intervals.Add(weightText);
if (TryParseWeightInterval(weightText, out var rangeStart, out var rangeEnd)
&& WeightInLeftOpenRightClosedInterval(chargeableWeight, rangeStart, rangeEnd))
{
weightMatched = true;
break;
}
}
if (!weightMatched)
{
var intervalText = intervals.Count > 0
? string.Join(", ", intervals.Distinct(StringComparer.OrdinalIgnoreCase))
: "无有效重量区间";
reasons.Add(
$"【{label}】价格项Id={price.Id}:计费重 {chargeableWeight}kg 未落入重量区间 [{intervalText}](左开右闭 (start,end])");
continue;
}
var priceTierKeyLabel = string.IsNullOrWhiteSpace(priceTierKey) ? "未登录/默认" : priceTierKey;
reasons.Add(
$"【{label}】价格项Id={price.Id}:明细已匹配但金额为0或客户组「{priceTierKeyLabel}」无对应单价");
}
return string.Join(";", reasons);
}
public static List<OrderFee> CalculateForecastReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
string zoneName,
string cargoAttribute,
string? priceTierKey) =>
CalculateReceivableFees(
priceSnapshot,
priceVersion,
priceEffectiveTime,
chargeableWeight,
new[] { zoneName },
cargoAttribute,
priceTierKey,
OrderFeeChargingStageCodes.Forecast);
public static List<OrderFee> CalculateForecastReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
string? priceTierKey) =>
CalculateReceivableFees(
priceSnapshot,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
OrderFeeChargingStageCodes.Forecast);
/// <summary>
/// 应收费用:卖价全部启用项参与;ChargingStage 由件重尺阶段决定。
/// </summary>
public static List<OrderFee> CalculateReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
string? priceTierKey,
string chargingStage,
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 CalculateReceivableFeesFromItems(
items,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
chargingStage,
warehouseId,
warehouseName);
}
/// <summary>
/// 应付费用:买价全部启用项参与;渠道买价不区分客户组。
/// </summary>
public static List<OrderFee> CalculatePayableFees(
IReadOnlyList<ServiceChannelPriceListOutput> prices,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
string chargingStage,
int? warehouseId = null,
string? warehouseName = null)
{
var items = prices.Select(p => new PayableBillingPriceItem(
p.Status, p.CostItem, p.Currency, p.Details));
return CalculatePayableFeesFromItems(
items,
priceVersion,
priceEffectiveTime,
new PriceDetailMatchContext(zoneKeys, cargoAttribute, chargeableWeight, warehouseId, warehouseName),
chargingStage);
}
/// <summary>
/// 预报阶段应付费用(买价快照)。
/// </summary>
public static List<OrderFee> CalculateForecastPayableFees(
IReadOnlyList<ServiceChannelPriceListOutput> prices,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
string zoneName,
string cargoAttribute,
int? warehouseId = null,
string? warehouseName = null) =>
CalculatePayableFees(
prices,
priceVersion,
priceEffectiveTime,
chargeableWeight,
new[] { zoneName },
cargoAttribute,
OrderFeeChargingStageCodes.Forecast,
warehouseId,
warehouseName);
public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
HasEnabledReceivablePriceItems(priceSnapshot);
public static bool HasEnabledPayablePriceItems(IReadOnlyList<ServiceChannelPriceListOutput> prices) =>
prices.Any(x => EnableStatusRules.IsEnabled(x.Status));
public static bool HasEnabledForecastPayablePriceItems(IReadOnlyList<ServiceChannelPriceListOutput> prices) =>
HasEnabledPayablePriceItems(prices);
public static string GetCargoAttributeLabel(string? cargoAttribute) => cargoAttribute switch
{
CargoAttributeCodes.Electronics => "3c&带电",
CargoAttributeCodes.Sensitive => "敏感货",
_ => "普货"
};
public static string MapCargoTypeToAttribute(string? cargoType)
{
if (string.IsNullOrWhiteSpace(cargoType))
{
return CargoAttributeCodes.General;
}
var value = cargoType.Trim();
if (value.Contains("敏感", StringComparison.OrdinalIgnoreCase))
{
return CargoAttributeCodes.Sensitive;
}
if (value.Contains("带电", StringComparison.OrdinalIgnoreCase)
|| value.Contains("3c", StringComparison.OrdinalIgnoreCase)
|| value.Equals("electronics", StringComparison.OrdinalIgnoreCase))
{
return CargoAttributeCodes.Electronics;
}
if (value.Equals(CargoAttributeCodes.General, StringComparison.OrdinalIgnoreCase)
|| value.Equals(CargoAttributeCodes.Electronics, StringComparison.OrdinalIgnoreCase)
|| value.Equals(CargoAttributeCodes.Sensitive, StringComparison.OrdinalIgnoreCase))
{
return value.ToLowerInvariant();
}
return CargoAttributeCodes.General;
}
public static (decimal Freight, decimal OtherFees, decimal TotalCost, string CostFormula) SummarizeFees(
IReadOnlyList<OrderFee> fees)
{
var summary = SummarizeQuoteFees(fees.Select(f => new BillingFeeLine
{
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()
{
Type = OrderWeightTypeCodes.Forecast,
Length = context.Length,
Width = context.Width,
Height = context.Height,
ActualWeight = context.ActualWeight,
Cubic = CalculateCubic(context.Length, context.Width, context.Height)
};
private static List<OrderFee> CalculateReceivableFeesFromItems(
IEnumerable<ReceivableBillingPriceItem> priceItems,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
string? priceTierKey,
string chargingStage,
int? warehouseId = null,
string? warehouseName = null)
{
if (chargeableWeight <= 0)
{
return [];
}
return CalculateReceivableFeeLines(
priceItems,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
chargingStage,
warehouseId,
warehouseName).Select(x => x.Fee).ToList();
}
private static List<BillingFeeLine> CalculateReceivableFeeLines(
IEnumerable<ReceivableBillingPriceItem> priceItems,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
string? priceTierKey,
string chargingStage,
int? warehouseId = null,
string? warehouseName = null)
{
var fees = new List<BillingFeeLine>();
foreach (var priceItem in priceItems.Where(x => EnableStatusRules.IsEnabled(x.Status)))
{
var detail = MatchPriceDetail(
priceItem.Details,
zoneKeys,
cargoAttribute,
chargeableWeight,
warehouseId,
warehouseName);
if (detail == null)
{
continue;
}
var amount = CalculateDetailAmount(
priceItem.Details,
detail,
zoneKeys,
cargoAttribute,
chargeableWeight,
priceTierKey,
warehouseId,
warehouseName);
var formulaPart = BuildDetailFormulaExpression(
priceItem.Details,
detail,
zoneKeys,
cargoAttribute,
chargeableWeight,
priceTierKey,
warehouseId,
warehouseName);
fees.Add(new BillingFeeLine
{
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
});
}
return fees;
}
private static List<OrderFee> CalculatePayableFeesFromItems(
IEnumerable<PayableBillingPriceItem> priceItems,
string priceVersion,
DateTime priceEffectiveTime,
PriceDetailMatchContext match,
string chargingStage)
{
if (match.ChargeableWeight <= 0)
{
return [];
}
var fees = new List<OrderFee>();
foreach (var priceItem in priceItems.Where(x => EnableStatusRules.IsEnabled(x.Status)))
{
var detail = MatchPriceDetail(
priceItem.Details,
match.ZoneKeys,
match.CargoAttribute,
match.ChargeableWeight,
match.WarehouseId,
match.WarehouseName);
if (detail == null)
{
continue;
}
var amount = CalculatePayableDetailAmount(priceItem.Details, detail, match);
if (amount <= 0)
{
continue;
}
fees.Add(new OrderFee
{
Type = OrderFeeTypeCodes.Payable,
CostItem = priceItem.CostItem,
Amount = amount,
Currency = priceItem.Currency,
PriceNo = priceVersion,
PriceEffectiveTime = priceEffectiveTime,
ChargingStage = chargingStage
});
}
return fees;
}
/// <summary>
/// 渠道买价明细金额:与下游服务商结算,仅使用 CalculatedValue / 续重配置,不区分客户组。
/// </summary>
private static decimal CalculatePayableDetailAmount(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
PriceDetailMatchContext match)
{
return NormalizeCalculationFormula(detail.CalculationFormula) switch
{
"unit" => CalculateBuyUnitPrice(detail, match.ChargeableWeight),
"total" => ResolveBuyPrice(detail),
"continue" => CalculateBuyContinueAmount(allDetails, detail, match),
_ => ResolveBuyPrice(detail)
};
}
private static decimal ResolveBuyPrice(ChannelPriceDetailOutput detail) =>
detail.CalculatedValue ?? 0;
private static decimal CalculateBuyUnitPrice(ChannelPriceDetailOutput detail, decimal chargeableWeight)
{
var unitPrice = ResolveBuyPrice(detail);
return Math.Round(chargeableWeight * unitPrice, 4, MidpointRounding.AwayFromZero);
}
private static decimal CalculateBuyContinueAmount(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
PriceDetailMatchContext match)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (TryGetWeightFactorText(factors, out var weightText)
&& TryParseWeightInterval(weightText, out var rangeStart, out _)
&& TryFindAdjacentBuyTotalBasePrice(allDetails, match, rangeStart, out var basePrice))
{
if (match.ChargeableWeight <= rangeStart)
{
return Math.Round(basePrice, 4, MidpointRounding.AwayFromZero);
}
var continueUnitPrice = ResolveBuyPrice(detail);
return Math.Round(
basePrice + (match.ChargeableWeight - rangeStart) * continueUnitPrice,
4,
MidpointRounding.AwayFromZero);
}
return CalculateBuyContinuePrice(detail, match.ChargeableWeight);
}
private static bool TryFindAdjacentBuyTotalBasePrice(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
PriceDetailMatchContext match,
decimal continueRangeStart,
out decimal basePrice)
{
basePrice = 0;
foreach (var candidate in allDetails)
{
var factors = ParseFactorDictionary(candidate.FactorValuesJson);
if (!ZoneMatches(factors, match.ZoneKeys)
|| !MatchCargoTypeFactor(factors, match.CargoAttribute)
|| !MatchWarehouseFactor(factors, match.WarehouseId, match.WarehouseName))
{
continue;
}
if (NormalizeCalculationFormula(candidate.CalculationFormula) != "total")
{
continue;
}
if (!TryGetWeightFactorText(factors, out var weightText)
|| !TryParseWeightInterval(weightText, out _, out var rangeEnd))
{
continue;
}
if (rangeEnd != continueRangeStart)
{
continue;
}
basePrice = ResolveBuyPrice(candidate);
return true;
}
return false;
}
private static decimal CalculateBuyContinuePrice(
ChannelPriceDetailOutput detail,
decimal chargeableWeight)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
var continuePricing = TryReadContinuePricing(factors, detail);
var firstWeight = continuePricing?.FirstWeight ?? 1M;
var firstPrice = continuePricing?.FirstPrice ?? ResolveBuyPrice(detail);
var continueUnit = continuePricing?.ContinueUnit ?? 0.5M;
var continuePrice = continuePricing?.ContinuePrice ?? firstPrice;
if (chargeableWeight <= firstWeight)
{
return Math.Round(firstPrice, 4, MidpointRounding.AwayFromZero);
}
var overflow = chargeableWeight - firstWeight;
var continueUnits = Math.Ceiling(overflow / continueUnit);
return Math.Round(firstPrice + continueUnits * continuePrice, 4, MidpointRounding.AwayFromZero);
}
private readonly record struct ReceivableBillingPriceItem(
byte Status,
string CostItem,
string PriceName,
string Currency,
List<ChannelPriceDetailOutput> Details);
private readonly record struct PayableBillingPriceItem(
byte Status,
string CostItem,
string Currency,
List<ChannelPriceDetailOutput> Details);
private static ChannelPriceDetailOutput? MatchPriceDetail(
IReadOnlyList<ChannelPriceDetailOutput> details,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
decimal chargeableWeight,
int? warehouseId = null,
string? warehouseName = null)
{
ChannelPriceDetailOutput? bestMatch = null;
var bestIntervalWidth = decimal.MaxValue;
foreach (var detail in details)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (!ZoneMatches(factors, zoneKeys))
{
continue;
}
if (!MatchCargoTypeFactor(factors, cargoAttribute))
{
continue;
}
if (!MatchWarehouseFactor(factors, warehouseId, warehouseName))
{
continue;
}
if (!TryGetWeightFactorText(factors, out var weightText))
{
if (bestMatch == null)
{
bestMatch = detail;
}
continue;
}
if (!TryParseWeightInterval(weightText, out var rangeStart, out var rangeEnd))
{
continue;
}
if (!WeightInLeftOpenRightClosedInterval(chargeableWeight, rangeStart, rangeEnd))
{
continue;
}
var width = rangeEnd - rangeStart;
if (width < bestIntervalWidth)
{
bestMatch = detail;
bestIntervalWidth = width;
}
}
return bestMatch;
}
/// <summary>
/// 解析卖价明细重量区间,遵循左开右闭 (start, end]。
/// 单值如 0.5 兼容为 (0, 0.5];显式区间支持 0,0.5 / 0-0.5。
/// </summary>
internal static bool TryParseWeightInterval(string? weightText, out decimal start, out decimal end)
{
start = 0;
end = 0;
if (string.IsNullOrWhiteSpace(weightText))
{
return false;
}
var text = weightText.Trim()
.Trim('[', ']', '(', ')');
char[] separators = [',', '-', ',', '~', '~'];
var parts = text.Split(separators, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2
&& decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out start)
&& decimal.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out end))
{
return end > start;
}
if (parts.Length == 1
&& decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out end)
&& end > 0)
{
start = 0;
return true;
}
return false;
}
internal static bool WeightInLeftOpenRightClosedInterval(decimal chargeableWeight, decimal start, decimal end) =>
chargeableWeight > start && chargeableWeight <= end;
private static bool ZoneMatches(Dictionary<string, string> factors, IReadOnlyList<string> zoneKeys)
{
if (!TryGetZoneFactorText(factors, out var zoneValue))
{
return true;
}
var expected = zoneValue.Trim();
return zoneKeys.Any(key => string.Equals(key.Trim(), expected, StringComparison.OrdinalIgnoreCase));
}
private static bool TryGetZoneFactorText(Dictionary<string, string> factors, out string zoneValue)
{
if (TryGetFactorValue(factors, out zoneValue, "分区", "价格分区", "zone"))
{
return !string.IsNullOrWhiteSpace(zoneValue);
}
zoneValue = string.Empty;
return false;
}
private static bool TryGetWeightFactorText(Dictionary<string, string> factors, out string weightText) =>
TryGetFactorValue(factors, out weightText, "重量", "weight") && !string.IsNullOrWhiteSpace(weightText);
/// <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;
}
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(
Dictionary<string, string> factors,
int? warehouseId,
string? warehouseName)
{
if (!TryGetFactorValue(factors, out var actual, "仓库", "warehouse") || string.IsNullOrWhiteSpace(actual))
{
return true;
}
var expected = actual.Trim();
if (warehouseId.HasValue && string.Equals(expected, warehouseId.Value.ToString(), StringComparison.Ordinal))
{
return true;
}
if (!string.IsNullOrWhiteSpace(warehouseName)
&& string.Equals(expected, warehouseName.Trim(), StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
private static string FormatWarehouseLabel(int? warehouseId, string? warehouseName)
{
if (!string.IsNullOrWhiteSpace(warehouseName))
{
return warehouseName.Trim();
}
return warehouseId.HasValue ? warehouseId.Value.ToString() : "未指定";
}
private static bool TryGetFactorValue(
Dictionary<string, string> factors,
out string value,
params string[] keys)
{
foreach (var key in keys)
{
if (factors.TryGetValue(key, out var hit) && !string.IsNullOrWhiteSpace(hit))
{
value = hit;
return true;
}
}
value = string.Empty;
return false;
}
private static Dictionary<string, string> ParseFactorDictionary(object? factorValuesJson)
{
if (factorValuesJson == null)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
if (factorValuesJson is JsonElement element)
{
return element.EnumerateObject()
.ToDictionary(x => x.Name, x => x.Value.ToString(), StringComparer.OrdinalIgnoreCase);
}
var json = JsonSerializer.Serialize(factorValuesJson, JsonOptions);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions)
?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
private static bool MatchFactorValue(Dictionary<string, string> factors, string key, string expected)
{
if (!factors.TryGetValue(key, out var actual) || string.IsNullOrWhiteSpace(actual))
{
return true;
}
return string.Equals(actual.Trim(), expected, StringComparison.OrdinalIgnoreCase);
}
private static decimal CalculateDetailAmount(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
decimal chargeableWeight,
string? priceTierKey,
int? warehouseId = null,
string? warehouseName = null)
{
return NormalizeCalculationFormula(detail.CalculationFormula) switch
{
"unit" => CalculateUnitPrice(detail, chargeableWeight, priceTierKey),
"total" => ResolvePriceTierKeyPrice(detail, priceTierKey) ?? detail.CalculatedValue ?? 0,
"continue" => CalculateContinueAmount(
allDetails,
detail,
zoneKeys,
cargoAttribute,
chargeableWeight,
priceTierKey,
warehouseId,
warehouseName),
_ => detail.CalculatedValue ?? 0
};
}
internal static string BuildDetailFormulaExpression(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
decimal chargeableWeight,
string? priceTierKey,
int? warehouseId = null,
string? warehouseName = null)
{
return NormalizeCalculationFormula(detail.CalculationFormula) switch
{
"unit" => BuildUnitFormulaExpression(detail, chargeableWeight, priceTierKey),
"total" => FormatDecimal(
ResolvePriceTierKeyPrice(detail, priceTierKey) ?? detail.CalculatedValue ?? 0),
"continue" => BuildContinueFormulaExpression(
allDetails,
detail,
zoneKeys,
cargoAttribute,
chargeableWeight,
priceTierKey,
warehouseId,
warehouseName),
_ => FormatDecimal(detail.CalculatedValue ?? 0)
};
}
private static string BuildUnitFormulaExpression(
ChannelPriceDetailOutput detail,
decimal chargeableWeight,
string? priceTierKey)
{
var unitPrice = ResolvePriceTierKeyPrice(detail, priceTierKey) ?? detail.CalculatedValue ?? 0;
return $"{FormatDecimal(unitPrice)}*{FormatDecimal(chargeableWeight)}";
}
private static string BuildContinueFormulaExpression(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
decimal chargeableWeight,
string? priceTierKey,
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,
priceTierKey,
warehouseId,
warehouseName,
out var basePrice))
{
if (chargeableWeight <= rangeStart)
{
return FormatDecimal(basePrice);
}
var continueUnitPrice = ResolvePriceTierKeyPrice(detail, priceTierKey) ?? 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 (!string.IsNullOrWhiteSpace(priceTierKey))
{
var groupPrice = ResolvePriceTierKeyPrice(detail, priceTierKey);
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>
private static decimal CalculateUnitPrice(
ChannelPriceDetailOutput detail,
decimal chargeableWeight,
string? priceTierKey)
{
var unitPrice = ResolvePriceTierKeyPrice(detail, priceTierKey) ?? detail.CalculatedValue ?? 0;
return Math.Round(chargeableWeight * unitPrice, 4, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 续单价:优先「下档总价 + (计费重-区间起点)×续单价」;否则回退单行 continuePricing(首重/续重进位)。
/// </summary>
private static decimal CalculateContinueAmount(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
decimal chargeableWeight,
string? priceTierKey,
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,
priceTierKey,
warehouseId,
warehouseName,
out var basePrice))
{
var continueUnitPrice = ResolvePriceTierKeyPrice(detail, priceTierKey) ?? detail.CalculatedValue ?? 0;
if (chargeableWeight <= rangeStart)
{
return Math.Round(basePrice, 4, MidpointRounding.AwayFromZero);
}
return Math.Round(
basePrice + (chargeableWeight - rangeStart) * continueUnitPrice,
4,
MidpointRounding.AwayFromZero);
}
return CalculateContinuePrice(detail, chargeableWeight, priceTierKey);
}
/// <summary>
/// 查找与续单价区间左端点衔接的下一档总价(如 (0,0.5] 总价 10 对应 (0.5,1] 续单价)。
/// </summary>
private static bool TryFindAdjacentTotalBasePrice(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
decimal continueRangeStart,
string? priceTierKey,
int? warehouseId,
string? warehouseName,
out decimal basePrice)
{
basePrice = 0;
foreach (var candidate in allDetails)
{
var factors = ParseFactorDictionary(candidate.FactorValuesJson);
if (!ZoneMatches(factors, zoneKeys)
|| !MatchCargoTypeFactor(factors, cargoAttribute)
|| !MatchWarehouseFactor(factors, warehouseId, warehouseName))
{
continue;
}
if (NormalizeCalculationFormula(candidate.CalculationFormula) != "total")
{
continue;
}
if (!TryGetWeightFactorText(factors, out var weightText)
|| !TryParseWeightInterval(weightText, out _, out var rangeEnd))
{
continue;
}
if (rangeEnd != continueRangeStart)
{
continue;
}
basePrice = ResolvePriceTierKeyPrice(candidate, priceTierKey) ?? candidate.CalculatedValue ?? 0;
return true;
}
return false;
}
/// <summary>
/// 计算公式字典编码归一化(calculation_formula)。
/// </summary>
internal static string NormalizeCalculationFormula(string? formula)
{
var value = formula?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value.ToLowerInvariant() switch
{
"unit" or "单价" => "unit",
"total" or "总价" or "固定价" => "total",
"continueunit" or "continue_unit" or "continueprice" or "continue_price" or "续单价" or "续重价" => "continue",
_ => value.ToLowerInvariant()
};
}
private static decimal CalculateContinuePrice(
ChannelPriceDetailOutput detail,
decimal chargeableWeight,
string? priceTierKey)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
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 (!string.IsNullOrWhiteSpace(priceTierKey))
{
var groupPrice = ResolvePriceTierKeyPrice(detail, priceTierKey);
if (groupPrice.HasValue)
{
firstPrice = groupPrice.Value;
}
}
if (chargeableWeight <= firstWeight)
{
return Math.Round(firstPrice, 4, MidpointRounding.AwayFromZero);
}
var overflow = chargeableWeight - firstWeight;
var continueUnits = Math.Ceiling(overflow / continueUnit);
return Math.Round(firstPrice + continueUnits * continuePrice, 4, MidpointRounding.AwayFromZero);
}
private static ContinuePricingConfig? TryReadContinuePricing(
Dictionary<string, string> factors,
ChannelPriceDetailOutput detail)
{
if (factors.TryGetValue("continuePricing", out var json) && !string.IsNullOrWhiteSpace(json))
{
return JsonSerializer.Deserialize<ContinuePricingConfig>(json, JsonOptions);
}
if (detail.FactorValuesJson is JsonElement element
&& element.TryGetProperty("continuePricing", out var continueNode))
{
return continueNode.Deserialize<ContinuePricingConfig>(JsonOptions);
}
if (factors.TryGetValue("首重", out var firstWeightText)
&& decimal.TryParse(firstWeightText, NumberStyles.Any, CultureInfo.InvariantCulture, out var firstWeight))
{
decimal.TryParse(factors.GetValueOrDefault("首重价"), NumberStyles.Any, CultureInfo.InvariantCulture, out var firstPrice);
decimal.TryParse(factors.GetValueOrDefault("续重单位"), NumberStyles.Any, CultureInfo.InvariantCulture, out var continueUnit);
decimal.TryParse(factors.GetValueOrDefault("续重价"), NumberStyles.Any, CultureInfo.InvariantCulture, out var continuePrice);
return new ContinuePricingConfig
{
FirstWeight = firstWeight,
FirstPrice = firstPrice > 0 ? firstPrice : detail.CalculatedValue ?? 0,
ContinueUnit = continueUnit > 0 ? continueUnit : 0.5M,
ContinuePrice = continuePrice > 0 ? continuePrice : detail.CalculatedValue ?? 0
};
}
return null;
}
private static decimal? ResolvePriceTierKeyPrice(ChannelPriceDetailOutput detail, string? priceTierKey)
{
if (detail.CustomerGroupValues == null)
{
return null;
}
var json = JsonSerializer.Serialize(detail.CustomerGroupValues, JsonOptions);
var map = JsonSerializer.Deserialize<Dictionary<string, decimal>>(json, JsonOptions);
if (map == null)
{
return null;
}
if (!string.IsNullOrWhiteSpace(priceTierKey)
&& map.TryGetValue(priceTierKey.Trim(), out var tierPrice))
{
return tierPrice;
}
if (map.TryGetValue("普通客户", out var normalPrice))
{
return normalPrice;
}
return null;
}
private static bool EvaluateCondition(string condition, decimal gw, decimal vw)
{
if (string.IsNullOrWhiteSpace(condition))
{
return true;
}
var expr = Regex.Replace(condition.Trim(), @"\bgw\b", gw.ToString(CultureInfo.InvariantCulture), RegexOptions.IgnoreCase);
expr = Regex.Replace(expr, @"\bvw\b", vw.ToString(CultureInfo.InvariantCulture), RegexOptions.IgnoreCase);
expr = expr.Replace("==", "=");
try
{
var result = new global::System.Data.DataTable().Compute(expr, string.Empty);
return result is bool boolResult && boolResult
|| result is int intResult && intResult != 0
|| result is decimal decimalResult && decimalResult != 0
|| result is double doubleResult && Math.Abs(doubleResult) > double.Epsilon;
}
catch
{
return false;
}
}
private static bool IsPostcodeInRange(string zipCode, string? left, string? right)
{
if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right))
{
return true;
}
if (long.TryParse(zipCode, out var zip)
&& long.TryParse(left, out var leftValue)
&& long.TryParse(right, out var rightValue))
{
return zip >= leftValue && zip <= rightValue;
}
return string.Compare(zipCode, left, StringComparison.OrdinalIgnoreCase) >= 0
&& string.Compare(zipCode, right, StringComparison.OrdinalIgnoreCase) <= 0;
}
private sealed class ContinuePricingConfig
{
public decimal FirstWeight { get; set; }
public decimal FirstPrice { get; set; }
public decimal ContinueUnit { get; set; }
public decimal ContinuePrice { get; set; }
}
}