All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s
各渠道异构价区与偏远不再复用产品卖价 zoneKeys;发布只存包引用,计费 hydrate 后硬失败。 Co-authored-by: Cursor <cursoragent@cursor.com>
2267 lines
80 KiB
C#
2267 lines
80 KiB
C#
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; }
|
||
}
|
||
}
|