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