using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders.Issues;
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;
///
/// 小包预报:计重、分区匹配、卖价算价。
///
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 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 ranges)
{
var hit = ranges
.OrderBy(x => x.StartValue)
.ThenBy(x => x.Id)
.FirstOrDefault(x => WeightInLeftOpenRightClosedInterval(rawWeight, x.StartValue, 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) =>
GeoPartitionMatcher.IsHit([rule], countryCode, province, city, zipCode);
public static bool IsAreaMatched(
KpiAreaInfoOutput rule,
string countryCode,
string? province,
string? city,
string? zipCode) =>
IsAreaMatched(MapInfoOutputToEntity(rule), countryCode, province, city, zipCode);
///
/// 从卖价快照内固化的分区规则解析分区键(不查库)。
///
public static IReadOnlyList? TryResolveZoneKeysFromPriceSnapshot(
SalesProductPricePublishSnapshotDto? priceSnapshot,
string countryCode,
string? province,
string? city,
string? zipCode) =>
TryResolveZoneKeysFromAreaInfos(
priceSnapshot?.PriceAreaInfos,
priceSnapshot?.PriceAreaId,
priceSnapshot?.PriceAreaName,
countryCode,
province,
city,
zipCode);
///
/// 从渠道买价快照分区规则解析分区键(不查库;与产品卖价独立)。
///
public static IReadOnlyList? TryResolveZoneKeysFromChannelPriceSnapshot(
ServiceChannelPricePublishSnapshotDto? priceSnapshot,
string countryCode,
string? province,
string? city,
string? zipCode) =>
TryResolveZoneKeysFromAreaInfos(
priceSnapshot?.PriceAreaInfos,
priceSnapshot?.PriceAreaId,
priceSnapshot?.PriceAreaName,
countryCode,
province,
city,
zipCode);
///
/// 按分区规则列表匹配地址并生成 zone 键。
///
public static IReadOnlyList? TryResolveZoneKeysFromAreaInfos(
IReadOnlyList? priceAreaInfos,
int? priceAreaId,
string? priceAreaName,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (priceAreaInfos == null || priceAreaInfos.Count == 0)
{
return null;
}
var matched = GeoPartitionMatcher.MatchSnapshot(
priceAreaInfos,
countryCode,
province,
city,
zipCode);
if (matched == null)
{
return null;
}
var areaId = matched.AreaId > 0 ? matched.AreaId : priceAreaId;
var areaName = !string.IsNullOrWhiteSpace(matched.AreaName)
? matched.AreaName
: priceAreaName;
var keys = BuildPriceZoneKeys(matched, areaName, areaId);
return keys.Count > 0 ? keys : null;
}
///
/// 查价仅填国家时:只使用该国分区规则对应的 zone 键(及与这些键重合的卖价明细分区)。
/// 未配置该国家时返回空;不把别国明细分区拿来估算。
///
public static IReadOnlyList CollectCountryEstimateZoneKeys(
SalesProductPricePublishSnapshotDto? priceSnapshot,
string countryCode)
{
if (priceSnapshot?.PriceAreaInfos == null
|| priceSnapshot.PriceAreaInfos.Count == 0
|| string.IsNullOrWhiteSpace(countryCode))
{
return [];
}
var countryRules = priceSnapshot.PriceAreaInfos
.Where(rule => string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
.ToList();
if (countryRules.Count == 0)
{
return [];
}
var allowed = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var rule in countryRules)
{
var areaId = rule.AreaId > 0 ? rule.AreaId : priceSnapshot.PriceAreaId;
var areaName = !string.IsNullOrWhiteSpace(rule.AreaName)
? rule.AreaName
: priceSnapshot.PriceAreaName;
foreach (var key in BuildPriceZoneKeys(rule, areaName, areaId))
{
if (!string.IsNullOrWhiteSpace(key))
{
allowed.Add(key.Trim());
}
}
}
var detailZones = CollectDistinctDetailZoneKeys(priceSnapshot);
var mapped = detailZones.Where(zone => allowed.Contains(zone)).ToList();
if (mapped.Count > 0)
{
return mapped;
}
return allowed.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
}
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 BuildPriceZoneKeys(KpiAreaInfo? matched, KpiArea? area)
{
var keys = new List();
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 BuildPriceZoneKeys(
KpiAreaSnapshotRuleDto matched,
string? areaName,
int? areaId)
{
var keys = new List();
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 BuildPriceZoneKeys(
KpiAreaInfoOutput matched,
string? areaName,
int? areaId)
{
var keys = new List();
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
};
///
/// 卖价/买价明细匹配共用上下文(价格分区、偏远分区、货物属性、计费重、仓库)。
///
internal readonly record struct PriceDetailMatchContext(
IReadOnlyList ZoneKeys,
string CargoAttribute,
decimal ChargeableWeight,
int? WarehouseId = null,
string? WarehouseName = null,
IReadOnlyList? RemoteZoneKeys = null);
///
/// 偏远命中时用生效 RemoteAreaId(及可选名称)作为偏远分区匹配键;非偏远返回空。
///
public static IReadOnlyList BuildRemoteZoneKeys(int? remoteAreaId, string? remoteAreaName = null)
{
if (remoteAreaId is not > 0)
{
return Array.Empty();
}
var keys = new List { remoteAreaId.Value.ToString(CultureInfo.InvariantCulture) };
if (!string.IsNullOrWhiteSpace(remoteAreaName))
{
keys.Add(remoteAreaName.Trim());
}
return keys;
}
public static List CalculateQuoteReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList zoneKeys,
string cargoAttribute,
string? priceTierKey,
int? warehouseId = null,
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList? remoteZoneKeys = null) =>
CalculateQuoteReceivableFeeLines(
priceSnapshot,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
warehouseId,
warehouseName,
conditionContext,
declaredSum,
declaredCurrency,
remoteZoneKeys).Select(x => x.Fee).ToList();
public static List CalculateQuoteReceivableFeeLines(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList zoneKeys,
string cargoAttribute,
string? priceTierKey,
int? warehouseId = null,
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList? remoteZoneKeys = null)
{
var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem(
p.Status, p.CostItem, p.PriceName, p.Currency, p.Details,
p.SerialNumber, p.ChargingCondition, p.CostUnit));
return CalculateReceivableFeeLines(
items,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
OrderFeeChargingStageCodes.Forecast,
warehouseId,
warehouseName,
conditionContext,
declaredSum,
declaredCurrency,
remoteZoneKeys);
}
public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
HasEnabledReceivablePriceItems(priceSnapshot);
public static bool HasEnabledReceivablePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
priceSnapshot.Prices.Any(x => EnableStatusRules.IsEnabled(x.Status));
///
/// 收集卖价快照中所有启用价格项的明细分区键(去重)。
///
public static IReadOnlyList CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot) =>
CollectDistinctDetailZoneKeys(
priceSnapshot.Prices.Select(p => (p.Status, p.Details)));
///
/// 收集渠道买价快照中所有启用价格项的明细分区键(去重)。
///
public static IReadOnlyList CollectDistinctDetailZoneKeys(ServiceChannelPricePublishSnapshotDto priceSnapshot) =>
CollectDistinctDetailZoneKeys(
priceSnapshot.Prices.Select(p => (p.Status, p.Details)));
private static IReadOnlyList CollectDistinctDetailZoneKeys(
IEnumerable<(byte Status, List? Details)> prices)
{
var zones = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var price in 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();
}
///
/// 查价未命中时,按价格项输出具体原因(分区/货物类型/重量区间等)。
///
public static string ExplainQuotePriceMiss(
SalesProductPricePublishSnapshotDto priceSnapshot,
decimal chargeableWeight,
IReadOnlyList 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();
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();
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 string ExplainPayablePriceMiss(
IReadOnlyList prices,
decimal chargeableWeight,
IReadOnlyList zoneKeys,
string cargoAttribute,
int? warehouseId = null,
string? warehouseName = null,
IReadOnlyList? remoteZoneKeys = null)
{
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
var warehouseLabel = FormatWarehouseLabel(warehouseId, warehouseName);
var enabledPrices = prices.Where(x => EnableStatusRules.IsEnabled(x.Status)).ToList();
if (enabledPrices.Count == 0)
{
return "买价中无启用的价格项";
}
var reasons = new List();
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}】分区不匹配,明细分区=[{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)
{
reasons.Add($"【{label}】货物类型不匹配,当前={cargoLabel}");
continue;
}
var warehouseMatchedDetails = cargoMatchedDetails.Where(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return MatchWarehouseFactor(factors, warehouseId, warehouseName);
}).ToList();
if (warehouseMatchedDetails.Count == 0)
{
reasons.Add($"【{label}】仓库不匹配,当前={warehouseLabel}");
continue;
}
var remoteMatchedDetails = warehouseMatchedDetails.Where(detail =>
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
return RemoteZoneMatches(factors, remoteZoneKeys);
}).ToList();
if (remoteMatchedDetails.Count == 0)
{
reasons.Add($"【{label}】偏远分区不匹配");
continue;
}
var weightMatched = false;
var intervals = new List();
foreach (var detail in remoteMatchedDetails)
{
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}】计费重 {chargeableWeight}kg 未落入重量区间 [{intervalText}]");
}
}
return string.Join(";", reasons);
}
public static List 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 CalculateForecastReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList zoneKeys,
string cargoAttribute,
string? priceTierKey) =>
CalculateReceivableFees(
priceSnapshot,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
OrderFeeChargingStageCodes.Forecast);
///
/// 应收费用:卖价全部启用项参与;ChargingStage 由件重尺阶段决定。
///
public static List CalculateReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList zoneKeys,
string cargoAttribute,
string? priceTierKey,
string chargingStage,
int? warehouseId = null,
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList? remoteZoneKeys = null)
{
var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem(
p.Status, p.CostItem, p.PriceName, p.Currency, p.Details,
p.SerialNumber, p.ChargingCondition, p.CostUnit));
return CalculateReceivableFeesFromItems(
items,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
chargingStage,
warehouseId,
warehouseName,
conditionContext,
declaredSum,
declaredCurrency,
remoteZoneKeys);
}
///
/// 应付费用:买价全部启用项参与;渠道买价不区分客户组。
///
public static List CalculatePayableFees(
IReadOnlyList prices,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList zoneKeys,
string cargoAttribute,
string chargingStage,
int? warehouseId = null,
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList? remoteZoneKeys = null)
{
var items = prices.Select(p => new PayableBillingPriceItem(
p.Status, p.CostItem, p.Currency, p.Details,
p.SerialNumber, p.ChargingCondition));
return CalculatePayableFeesFromItems(
items,
priceVersion,
priceEffectiveTime,
new PriceDetailMatchContext(
zoneKeys,
cargoAttribute,
chargeableWeight,
warehouseId,
warehouseName,
remoteZoneKeys),
chargingStage,
conditionContext,
declaredSum,
declaredCurrency);
}
///
/// 预报阶段应付费用(买价快照)。
///
public static List CalculateForecastPayableFees(
IReadOnlyList prices,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
string zoneName,
string cargoAttribute,
int? warehouseId = null,
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null) =>
CalculatePayableFees(
prices,
priceVersion,
priceEffectiveTime,
chargeableWeight,
new[] { zoneName },
cargoAttribute,
OrderFeeChargingStageCodes.Forecast,
warehouseId,
warehouseName,
conditionContext,
declaredSum,
declaredCurrency);
///
/// 计费条件:空/null/「无」视为命中;否则按中文公式求值,求值失败视为未命中。
///
public static bool IsBillingConditionMet(string? condition, OrderRuleEvaluationContext? context)
{
if (string.IsNullOrWhiteSpace(condition))
{
return true;
}
var trimmed = condition.Trim();
if (string.Equals(trimmed, "无", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (context == null)
{
return false;
}
return ChineseFormulaEvaluator.TryEvaluate(trimmed, context, out var hit) && hit;
}
public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
HasEnabledReceivablePriceItems(priceSnapshot);
public static bool HasEnabledPayablePriceItems(IReadOnlyList prices) =>
prices.Any(x => EnableStatusRules.IsEnabled(x.Status));
public static bool HasEnabledForecastPayablePriceItems(IReadOnlyList 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 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 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()
};
}
public static bool IsFreightCostItem(string costItem) =>
!string.IsNullOrWhiteSpace(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" => "操作费",
"proxyPacking" => "代打包费",
"insurance" => "保险费",
_ => 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 CalculateReceivableFeesFromItems(
IEnumerable priceItems,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList zoneKeys,
string cargoAttribute,
string? priceTierKey,
string chargingStage,
int? warehouseId = null,
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList? remoteZoneKeys = null) =>
CalculateReceivableFeeLines(
priceItems,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
chargingStage,
warehouseId,
warehouseName,
conditionContext,
declaredSum,
declaredCurrency,
remoteZoneKeys).Select(x => x.Fee).ToList();
private static List CalculateReceivableFeeLines(
IEnumerable priceItems,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList zoneKeys,
string cargoAttribute,
string? priceTierKey,
string chargingStage,
int? warehouseId = null,
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList? remoteZoneKeys = null)
{
var pass1 = new List<(int SerialNumber, BillingFeeLine Line)>();
var percentFreightPending = new List<(int SerialNumber, ReceivableBillingPriceItem PriceItem, ChannelPriceDetailOutput Detail)>();
foreach (var priceItem in priceItems.Where(x => EnableStatusRules.IsEnabled(x.Status)))
{
if (!IsBillingConditionMet(priceItem.ChargingCondition, conditionContext))
{
continue;
}
if (chargeableWeight <= 0 && !CanBillWithoutChargeableWeight(priceItem.Details))
{
continue;
}
var detail = MatchPriceDetail(
priceItem.Details,
zoneKeys,
cargoAttribute,
chargeableWeight,
warehouseId,
warehouseName,
remoteZoneKeys);
if (detail == null)
{
continue;
}
var formula = NormalizeCalculationFormula(detail.CalculationFormula);
if (formula == "percent_freight")
{
percentFreightPending.Add((priceItem.SerialNumber, priceItem, detail));
continue;
}
if (formula == "percent_declared"
&& !string.IsNullOrWhiteSpace(declaredCurrency)
&& !string.IsNullOrWhiteSpace(priceItem.Currency)
&& !string.Equals(declaredCurrency.Trim(), priceItem.Currency.Trim(), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var amount = CalculateDetailAmount(
priceItem.Details,
detail,
zoneKeys,
cargoAttribute,
chargeableWeight,
priceTierKey,
warehouseId,
warehouseName,
declaredSum);
if (amount <= 0)
{
continue;
}
var formulaPart = BuildDetailFormulaExpression(
priceItem.Details,
detail,
zoneKeys,
cargoAttribute,
chargeableWeight,
priceTierKey,
warehouseId,
warehouseName,
declaredSum);
pass1.Add((priceItem.SerialNumber, 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
}));
}
var pass1Deduped = DedupeFeeLinesByCostItem(pass1);
var candidates = new List<(int SerialNumber, BillingFeeLine Line)>(pass1);
foreach (var pending in percentFreightPending)
{
var freightBase = SumFreightBaseAmount(
pass1Deduped.Select(x => x.Fee),
pending.PriceItem.Currency);
if (freightBase <= 0)
{
continue;
}
var rate = ResolvePriceTierKeyPrice(pending.Detail, priceTierKey) ?? pending.Detail.CalculatedValue ?? 0;
var amount = CalculatePercentFreightAmount(rate, freightBase);
if (amount <= 0)
{
continue;
}
candidates.Add((pending.SerialNumber, new BillingFeeLine
{
Fee = new OrderFee
{
Type = OrderFeeTypeCodes.Receivable,
CostItem = pending.PriceItem.CostItem,
Amount = amount,
Currency = pending.PriceItem.Currency,
PriceNo = priceVersion,
PriceEffectiveTime = priceEffectiveTime,
ChargingStage = chargingStage
},
PriceName = pending.PriceItem.PriceName,
FormulaPart = BuildPercentFreightFormulaExpression(rate, freightBase)
}));
}
return DedupeFeeLinesByCostItem(candidates);
}
private static List CalculatePayableFeesFromItems(
IEnumerable priceItems,
string priceVersion,
DateTime priceEffectiveTime,
PriceDetailMatchContext match,
string chargingStage,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null)
{
var pass1 = new List<(int SerialNumber, OrderFee Fee)>();
var percentFreightPending = new List<(int SerialNumber, PayableBillingPriceItem PriceItem, ChannelPriceDetailOutput Detail)>();
foreach (var priceItem in priceItems.Where(x => EnableStatusRules.IsEnabled(x.Status)))
{
if (!IsBillingConditionMet(priceItem.ChargingCondition, conditionContext))
{
continue;
}
if (match.ChargeableWeight <= 0 && !CanBillWithoutChargeableWeight(priceItem.Details))
{
continue;
}
var detail = MatchPriceDetail(
priceItem.Details,
match.ZoneKeys,
match.CargoAttribute,
match.ChargeableWeight,
match.WarehouseId,
match.WarehouseName,
match.RemoteZoneKeys);
if (detail == null)
{
continue;
}
var formula = NormalizeCalculationFormula(detail.CalculationFormula);
if (formula == "percent_freight")
{
percentFreightPending.Add((priceItem.SerialNumber, priceItem, detail));
continue;
}
if (formula == "percent_declared"
&& !string.IsNullOrWhiteSpace(declaredCurrency)
&& !string.IsNullOrWhiteSpace(priceItem.Currency)
&& !string.Equals(declaredCurrency.Trim(), priceItem.Currency.Trim(), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var amount = CalculatePayableDetailAmount(priceItem.Details, detail, match, declaredSum);
if (amount <= 0)
{
continue;
}
pass1.Add((priceItem.SerialNumber, new OrderFee
{
Type = OrderFeeTypeCodes.Payable,
CostItem = priceItem.CostItem,
Amount = amount,
Currency = priceItem.Currency,
PriceNo = priceVersion,
PriceEffectiveTime = priceEffectiveTime,
ChargingStage = chargingStage
}));
}
var pass1Deduped = DedupeFeesByCostItem(pass1);
var candidates = new List<(int SerialNumber, OrderFee Fee)>(pass1);
foreach (var pending in percentFreightPending)
{
var freightBase = SumFreightBaseAmount(pass1Deduped, pending.PriceItem.Currency);
if (freightBase <= 0)
{
continue;
}
var rate = ResolveBuyPrice(pending.Detail);
var amount = CalculatePercentFreightAmount(rate, freightBase);
if (amount <= 0)
{
continue;
}
candidates.Add((pending.SerialNumber, new OrderFee
{
Type = OrderFeeTypeCodes.Payable,
CostItem = pending.PriceItem.CostItem,
Amount = amount,
Currency = pending.PriceItem.Currency,
PriceNo = priceVersion,
PriceEffectiveTime = priceEffectiveTime,
ChargingStage = chargingStage
}));
}
return DedupeFeesByCostItem(candidates);
}
private static decimal SumFreightBaseAmount(IEnumerable fees, string? currency)
{
var query = fees.Where(x => IsFreightCostItem(x.CostItem));
if (!string.IsNullOrWhiteSpace(currency))
{
query = query.Where(x =>
string.IsNullOrWhiteSpace(x.Currency)
|| string.Equals(x.Currency.Trim(), currency.Trim(), StringComparison.OrdinalIgnoreCase));
}
return query.Sum(x => x.Amount);
}
private static List DedupeFeeLinesByCostItem(
IReadOnlyList<(int SerialNumber, BillingFeeLine Line)> candidates) =>
candidates
.GroupBy(x => x.Line.Fee.CostItem, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderBy(x => x.SerialNumber).First().Line)
.ToList();
private static List DedupeFeesByCostItem(
IReadOnlyList<(int SerialNumber, OrderFee Fee)> candidates) =>
candidates
.GroupBy(x => x.Fee.CostItem, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderBy(x => x.SerialNumber).First().Fee)
.ToList();
private static bool CanBillWithoutChargeableWeight(IReadOnlyList details)
{
foreach (var detail in details)
{
var formula = NormalizeCalculationFormula(detail.CalculationFormula);
// percent_freight 依赖同批次运费;无计费重时先放行,pass-2 若无运费则跳过。
if (formula is not ("percent_declared" or "percent_freight" or "total"))
{
continue;
}
var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (!TryGetWeightFactorText(factors, out _))
{
return true;
}
}
return false;
}
///
/// 渠道买价明细金额:与下游服务商结算,仅使用 CalculatedValue / 续重配置,不区分客户组。
///
private static decimal CalculatePayableDetailAmount(
IReadOnlyList allDetails,
ChannelPriceDetailOutput detail,
PriceDetailMatchContext match,
decimal? declaredSum = null)
{
return NormalizeCalculationFormula(detail.CalculationFormula) switch
{
"unit" => CalculateBuyUnitPrice(detail, match.ChargeableWeight),
"total" => ResolveBuyPrice(detail),
"continue" => CalculateBuyContinueAmount(allDetails, detail, match),
"percent_declared" => CalculatePercentDeclaredAmount(ResolveBuyPrice(detail), declaredSum),
// percent_freight 在两趟计算的第二趟处理
"percent_freight" => 0,
_ => 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 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 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)
|| !RemoteZoneMatches(factors, match.RemoteZoneKeys)
|| !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 Details,
int SerialNumber = 0,
string? ChargingCondition = null,
string? CostUnit = null);
private readonly record struct PayableBillingPriceItem(
byte Status,
string CostItem,
string Currency,
List Details,
int SerialNumber = 0,
string? ChargingCondition = null);
private static ChannelPriceDetailOutput? MatchPriceDetail(
IReadOnlyList details,
IReadOnlyList zoneKeys,
string cargoAttribute,
decimal chargeableWeight,
int? warehouseId = null,
string? warehouseName = null,
IReadOnlyList? remoteZoneKeys = null)
{
ChannelPriceDetailOutput? bestMatch = null;
var bestIntervalWidth = decimal.MaxValue;
foreach (var detail in details)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (!ZoneMatches(factors, zoneKeys))
{
continue;
}
if (!RemoteZoneMatches(factors, remoteZoneKeys))
{
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;
}
///
/// 解析卖价明细重量区间,遵循左开右闭 (start, end]。
/// 单值如 0.5 兼容为 (0, 0.5];显式区间支持 0,0.5 / 0-0.5。
///
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 factors, IReadOnlyList 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 RemoteZoneMatches(Dictionary factors, IReadOnlyList? remoteZoneKeys)
{
if (!TryGetRemoteZoneFactorText(factors, out var remoteZoneValue))
{
return true;
}
if (remoteZoneKeys == null || remoteZoneKeys.Count == 0)
{
return false;
}
var expected = remoteZoneValue.Trim();
return remoteZoneKeys.Any(key =>
!string.IsNullOrWhiteSpace(key)
&& string.Equals(key.Trim(), expected, StringComparison.OrdinalIgnoreCase));
}
private static bool TryGetZoneFactorText(Dictionary factors, out string zoneValue)
{
if (TryGetFactorValue(factors, out zoneValue, "分区", "价格分区", "zone"))
{
return !string.IsNullOrWhiteSpace(zoneValue);
}
zoneValue = string.Empty;
return false;
}
private static bool TryGetRemoteZoneFactorText(Dictionary factors, out string remoteZoneValue)
{
if (TryGetFactorValue(factors, out remoteZoneValue, "偏远分区", "remoteZone"))
{
return !string.IsNullOrWhiteSpace(remoteZoneValue);
}
remoteZoneValue = string.Empty;
return false;
}
private static bool TryGetWeightFactorText(Dictionary factors, out string weightText) =>
TryGetFactorValue(factors, out weightText, "重量", "weight") && !string.IsNullOrWhiteSpace(weightText);
///
/// 货物类型因子匹配:明细可能存中文标签(普货)或英文 code(general),查价侧统一为 cargoAttribute。
///
private static bool MatchCargoTypeFactor(Dictionary 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 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 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 ParseFactorDictionary(object? factorValuesJson)
{
if (factorValuesJson == null)
{
return new Dictionary(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>(json, JsonOptions)
?? new Dictionary(StringComparer.OrdinalIgnoreCase);
}
private static bool MatchFactorValue(Dictionary 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 allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList zoneKeys,
string cargoAttribute,
decimal chargeableWeight,
string? priceTierKey,
int? warehouseId = null,
string? warehouseName = null,
decimal? declaredSum = 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),
"percent_declared" => CalculatePercentDeclaredAmount(
ResolvePriceTierKeyPrice(detail, priceTierKey) ?? detail.CalculatedValue ?? 0,
declaredSum),
// percent_freight 在两趟计算的第二趟处理
"percent_freight" => 0,
_ => detail.CalculatedValue ?? 0
};
}
internal static string BuildDetailFormulaExpression(
IReadOnlyList allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList zoneKeys,
string cargoAttribute,
decimal chargeableWeight,
string? priceTierKey,
int? warehouseId = null,
string? warehouseName = null,
decimal? declaredSum = null,
decimal? freightBase = 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),
"percent_declared" => BuildPercentDeclaredFormulaExpression(
ResolvePriceTierKeyPrice(detail, priceTierKey) ?? detail.CalculatedValue ?? 0,
declaredSum),
"percent_freight" => BuildPercentFreightFormulaExpression(
ResolvePriceTierKeyPrice(detail, priceTierKey) ?? detail.CalculatedValue ?? 0,
freightBase ?? 0M),
_ => FormatDecimal(detail.CalculatedValue ?? 0)
};
}
private static decimal CalculatePercentDeclaredAmount(decimal rate, decimal? declaredSum)
{
if (!declaredSum.HasValue || declaredSum.Value <= 0 || rate <= 0)
{
return 0;
}
return Math.Round(declaredSum.Value * rate / 100M, 2, MidpointRounding.AwayFromZero);
}
private static string BuildPercentDeclaredFormulaExpression(decimal rate, decimal? declaredSum)
{
var sum = declaredSum is > 0 ? declaredSum.Value : 0M;
return $"{FormatDecimal(sum)}*{FormatDecimal(rate)}/100";
}
private static decimal CalculatePercentFreightAmount(decimal rate, decimal freightBase)
{
if (freightBase <= 0 || rate <= 0)
{
return 0;
}
return Math.Round(freightBase * rate / 100M, 2, MidpointRounding.AwayFromZero);
}
private static string BuildPercentFreightFormulaExpression(decimal rate, decimal freightBase) =>
$"{FormatDecimal(freightBase)}*{FormatDecimal(rate)}/100";
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 allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList 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)}";
}
///
/// 单价:计费重 × 单价。
///
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);
}
///
/// 续单价:优先「下档总价 + (计费重-区间起点)×续单价」;否则回退单行 continuePricing(首重/续重进位)。
///
private static decimal CalculateContinueAmount(
IReadOnlyList allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList 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);
}
///
/// 查找与续单价区间左端点衔接的下一档总价(如 (0,0.5] 总价 10 对应 (0.5,1] 续单价)。
///
private static bool TryFindAdjacentTotalBasePrice(
IReadOnlyList allDetails,
IReadOnlyList 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;
}
///
/// 计算公式字典编码归一化(calculation_formula)。
///
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",
"percent_declared" or "percentdeclared" or "申报比例" or "申报金额比例" or "货值比例" => "percent_declared",
"percent_freight" or "percentfreight" or "运费比例" or "运费百分比" => "percent_freight",
_ => 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 factors,
ChannelPriceDetailOutput detail)
{
if (factors.TryGetValue("continuePricing", out var json) && !string.IsNullOrWhiteSpace(json))
{
return JsonSerializer.Deserialize(json, JsonOptions);
}
if (detail.FactorValuesJson is JsonElement element
&& element.TryGetProperty("continuePricing", out var continueNode))
{
return continueNode.Deserialize(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>(json, JsonOptions);
if (map == null)
{
return null;
}
// 「全部客户」优先:任意价区键共用这一列
if (TryGetMapPrice(map, out var allPrice, "全部客户", "all"))
{
return allPrice;
}
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 TryGetMapPrice(
Dictionary map,
out decimal price,
params string[] keys)
{
foreach (var key in keys)
{
if (map.TryGetValue(key, out price))
{
return true;
}
foreach (var pair in map)
{
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
{
price = pair.Value;
return true;
}
}
}
price = 0;
return false;
}
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
{
throw new InvalidOperationException($"计重规则条件无效:{condition}");
}
}
private sealed class ContinuePricingConfig
{
public decimal FirstWeight { get; set; }
public decimal FirstPrice { get; set; }
public decimal ContinueUnit { get; set; }
public decimal ContinuePrice { get; set; }
}
}