admin/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
zzh 35156eedf4
All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s
feat: 渠道买价/规则发布钉 KPI 分区包,排货应付按渠道价区解析
各渠道异构价区与偏远不再复用产品卖价 zoneKeys;发布只存包引用,计费 hydrate 后硬失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 17:19:39 +08:00

2267 lines
80 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.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;
/// <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.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);
/// <summary>
/// 从卖价快照内固化的分区规则解析分区键(不查库)。
/// </summary>
public static IReadOnlyList<string>? TryResolveZoneKeysFromPriceSnapshot(
SalesProductPricePublishSnapshotDto? priceSnapshot,
string countryCode,
string? province,
string? city,
string? zipCode) =>
TryResolveZoneKeysFromAreaInfos(
priceSnapshot?.PriceAreaInfos,
priceSnapshot?.PriceAreaId,
priceSnapshot?.PriceAreaName,
countryCode,
province,
city,
zipCode);
/// <summary>
/// 从渠道买价快照分区规则解析分区键(不查库;与产品卖价独立)。
/// </summary>
public static IReadOnlyList<string>? TryResolveZoneKeysFromChannelPriceSnapshot(
ServiceChannelPricePublishSnapshotDto? priceSnapshot,
string countryCode,
string? province,
string? city,
string? zipCode) =>
TryResolveZoneKeysFromAreaInfos(
priceSnapshot?.PriceAreaInfos,
priceSnapshot?.PriceAreaId,
priceSnapshot?.PriceAreaName,
countryCode,
province,
city,
zipCode);
/// <summary>
/// 按分区规则列表匹配地址并生成 zone 键。
/// </summary>
public static IReadOnlyList<string>? TryResolveZoneKeysFromAreaInfos(
IReadOnlyList<KpiAreaSnapshotRuleDto>? 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;
}
/// <summary>
/// 查价仅填国家时:只使用该国分区规则对应的 zone 键(及与这些键重合的卖价明细分区)。
/// 未配置该国家时返回空;不把别国明细分区拿来估算。
/// </summary>
public static IReadOnlyList<string> 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<string>(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<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,
IReadOnlyList<string>? RemoteZoneKeys = null);
/// <summary>
/// 偏远命中时用生效 RemoteAreaId(及可选名称)作为偏远分区匹配键;非偏远返回空。
/// </summary>
public static IReadOnlyList<string> BuildRemoteZoneKeys(int? remoteAreaId, string? remoteAreaName = null)
{
if (remoteAreaId is not > 0)
{
return Array.Empty<string>();
}
var keys = new List<string> { remoteAreaId.Value.ToString(CultureInfo.InvariantCulture) };
if (!string.IsNullOrWhiteSpace(remoteAreaName))
{
keys.Add(remoteAreaName.Trim());
}
return keys;
}
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,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList<string>? remoteZoneKeys = null) =>
CalculateQuoteReceivableFeeLines(
priceSnapshot,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
warehouseId,
warehouseName,
conditionContext,
declaredSum,
declaredCurrency,
remoteZoneKeys).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,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList<string>? 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));
/// <summary>
/// 收集卖价快照中所有启用价格项的明细分区键(去重)。
/// </summary>
public static IReadOnlyList<string> CollectDistinctDetailZoneKeys(SalesProductPricePublishSnapshotDto priceSnapshot) =>
CollectDistinctDetailZoneKeys(
priceSnapshot.Prices.Select(p => (p.Status, p.Details)));
/// <summary>
/// 收集渠道买价快照中所有启用价格项的明细分区键(去重)。
/// </summary>
public static IReadOnlyList<string> CollectDistinctDetailZoneKeys(ServiceChannelPricePublishSnapshotDto priceSnapshot) =>
CollectDistinctDetailZoneKeys(
priceSnapshot.Prices.Select(p => (p.Status, p.Details)));
private static IReadOnlyList<string> CollectDistinctDetailZoneKeys(
IEnumerable<(byte Status, List<ChannelPriceDetailOutput>? Details)> prices)
{
var zones = new HashSet<string>(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();
}
/// <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);
}
/// <summary>
/// 买价未命中时,按价格项输出具体原因(分区/货物类型/重量区间等)。
/// </summary>
public static string ExplainPayablePriceMiss(
IReadOnlyList<ServiceChannelPriceListOutput> prices,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
int? warehouseId = null,
string? warehouseName = null,
IReadOnlyList<string>? 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<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}】分区不匹配,明细分区=[{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<string>();
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<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,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList<string>? 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);
}
/// <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,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList<string>? 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);
}
/// <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,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null) =>
CalculatePayableFees(
prices,
priceVersion,
priceEffectiveTime,
chargeableWeight,
new[] { zoneName },
cargoAttribute,
OrderFeeChargingStageCodes.Forecast,
warehouseId,
warehouseName,
conditionContext,
declaredSum,
declaredCurrency);
/// <summary>
/// 计费条件:空/null/「无」视为命中;否则按中文公式求值,求值失败视为未命中。
/// </summary>
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<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()
};
}
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<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,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList<string>? 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<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,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null,
IReadOnlyList<string>? 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<OrderFee> CalculatePayableFeesFromItems(
IEnumerable<PayableBillingPriceItem> 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<OrderFee> 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<BillingFeeLine> 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<OrderFee> 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<ChannelPriceDetailOutput> 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;
}
/// <summary>
/// 渠道买价明细金额:与下游服务商结算,仅使用 CalculatedValue / 续重配置,不区分客户组。
/// </summary>
private static decimal CalculatePayableDetailAmount(
IReadOnlyList<ChannelPriceDetailOutput> 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<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)
|| !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<ChannelPriceDetailOutput> Details,
int SerialNumber = 0,
string? ChargingCondition = null,
string? CostUnit = null);
private readonly record struct PayableBillingPriceItem(
byte Status,
string CostItem,
string Currency,
List<ChannelPriceDetailOutput> Details,
int SerialNumber = 0,
string? ChargingCondition = null);
private static ChannelPriceDetailOutput? MatchPriceDetail(
IReadOnlyList<ChannelPriceDetailOutput> details,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
decimal chargeableWeight,
int? warehouseId = null,
string? warehouseName = null,
IReadOnlyList<string>? 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;
}
/// <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 RemoteZoneMatches(Dictionary<string, string> factors, IReadOnlyList<string>? 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<string, string> factors, out string zoneValue)
{
if (TryGetFactorValue(factors, out zoneValue, "分区", "价格分区", "zone"))
{
return !string.IsNullOrWhiteSpace(zoneValue);
}
zoneValue = string.Empty;
return false;
}
private static bool TryGetRemoteZoneFactorText(Dictionary<string, string> factors, out string remoteZoneValue)
{
if (TryGetFactorValue(factors, out remoteZoneValue, "偏远分区", "remoteZone"))
{
return !string.IsNullOrWhiteSpace(remoteZoneValue);
}
remoteZoneValue = 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,
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<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
IReadOnlyList<string> 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<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",
"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<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 (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<string, decimal> 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; }
}
}