feat: support remote-zone factor matching and all-customer price tier

Split price/remote zone matching in billing and prefer the 全部客户 column for any price tier key.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-08-02 18:22:17 +08:00
parent c8107cb565
commit cc100d40cf
6 changed files with 256 additions and 21 deletions

View File

@ -118,6 +118,11 @@ public partial class SalesProductService
: OmsDocHelper.SerializeJson(additionalServices)
});
var remoteAreaId = product.RemoteAreaId is > 0 ? product.RemoteAreaId : rulesSnapshot.RemoteAreaId;
var remoteZoneKeys = isRemote
? ParcelBillingCalculator.BuildRemoteZoneKeys(remoteAreaId, rulesSnapshot.RemoteAreaName)
: Array.Empty<string>();
var receivableLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
priceSnapshot,
priceVersion,
@ -130,7 +135,8 @@ public partial class SalesProductService
warehouseName,
conditionContext,
input.DeclaredSum,
input.DeclaredCurrency);
input.DeclaredCurrency,
remoteZoneKeys);
if (receivableLines.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(priceSnapshot))
{
@ -165,6 +171,7 @@ public partial class SalesProductService
input.WarehouseId,
warehouseName,
isRemote,
remoteZoneKeys,
input.DeclaredSum,
input.DeclaredCurrency,
priceVersion,
@ -307,6 +314,7 @@ public partial class SalesProductService
int? warehouseId,
string? warehouseName,
bool isRemote,
IReadOnlyList<string> remoteZoneKeys,
decimal? declaredSum,
string? declaredCurrency,
string priceVersion,
@ -333,6 +341,17 @@ public partial class SalesProductService
return [];
}
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == provider.ServiceChannelId.Value);
var payableRemoteZoneKeys = isRemote
? ParcelBillingCalculator.BuildRemoteZoneKeys(
channel?.RemoteAreaId is > 0 ? channel.RemoteAreaId : null)
: Array.Empty<string>();
// 渠道未配偏远分区时回落产品偏远键,便于模拟偏远费行
if (isRemote && payableRemoteZoneKeys.Count == 0)
{
payableRemoteZoneKeys = remoteZoneKeys;
}
var payableContext = OrderRuleEvaluationContext.FromOrderOnly(new Order
{
ProductCode = product.ProductCode,
@ -356,7 +375,8 @@ public partial class SalesProductService
warehouseName,
payableContext,
declaredSum,
declaredCurrency);
declaredCurrency,
payableRemoteZoneKeys);
if (payable.Count == 0)
{

View File

@ -689,6 +689,13 @@ public partial class OrderService
string? warehouseName = null)
{
var conditionContext = BuildBillingConditionContext(order);
var remoteAreaId = order.IsRemote > 0
? (snapshotBinding.Rules?.RemoteAreaId
?? snapshotBinding.Rules?.Product?.RemoteAreaId)
: null;
var remoteZoneKeys = ParcelBillingCalculator.BuildRemoteZoneKeys(
remoteAreaId is > 0 ? remoteAreaId : null,
snapshotBinding.Rules?.RemoteAreaName);
var fees = ParcelBillingCalculator.CalculateReceivableFees(
snapshotBinding.Price,
snapshotBinding.PriceVersion,
@ -702,7 +709,8 @@ public partial class OrderService
warehouseName,
conditionContext,
order.DeclaredSum,
order.DeclaredCurrency);
order.DeclaredCurrency,
remoteZoneKeys);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price))
{
@ -781,6 +789,9 @@ public partial class OrderService
channelIsRemote ? (byte)1 : (byte)0);
var channelPriceBinding = await ResolveChannelPriceBindingAsync(channel.Id, bindTime);
var channelRemoteZoneKeys = channelIsRemote
? ParcelBillingCalculator.BuildRemoteZoneKeys(channel.RemoteAreaId)
: Array.Empty<string>();
var previewFees = ParcelBillingCalculator.CalculatePayableFees(
channelPriceBinding.Prices,
channelPriceBinding.PriceVersion,
@ -793,7 +804,8 @@ public partial class OrderService
warehouseName,
payableContext,
order.DeclaredSum,
order.DeclaredCurrency);
order.DeclaredCurrency,
channelRemoteZoneKeys);
if (previewFees.Count == 0)
{
continue;

View File

@ -595,6 +595,12 @@ public partial class OrderService
context.PostalCode);
var conditionContext = BuildQuoteBillingConditionContext(context, isRemote);
var remoteZoneKeys = isRemote
? ParcelBillingCalculator.BuildRemoteZoneKeys(
ResolveEffectiveRemoteAreaId(product, snapshotBinding.Rules),
snapshotBinding.Rules?.RemoteAreaName)
: Array.Empty<string>();
foreach (var zoneAttempt in zoneAttempts)
{
var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
@ -609,7 +615,8 @@ public partial class OrderService
context.WarehouseName,
conditionContext,
context.DeclaredSum,
context.DeclaredCurrency);
context.DeclaredCurrency,
remoteZoneKeys);
if (feeLines.Count == 0)
{
continue;

View File

@ -374,14 +374,34 @@ internal static class ParcelBillingCalculator
};
/// <summary>
/// 卖价/买价明细匹配共用上下文(分区、货物属性、计费重、仓库)。
/// 卖价/买价明细匹配共用上下文(价格分区、偏远分区、货物属性、计费重、仓库)。
/// </summary>
internal readonly record struct PriceDetailMatchContext(
IReadOnlyList<string> ZoneKeys,
string CargoAttribute,
decimal ChargeableWeight,
int? WarehouseId = null,
string? WarehouseName = 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,
@ -395,7 +415,8 @@ internal static class ParcelBillingCalculator
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null) =>
string? declaredCurrency = null,
IReadOnlyList<string>? remoteZoneKeys = null) =>
CalculateQuoteReceivableFeeLines(
priceSnapshot,
priceVersion,
@ -408,7 +429,8 @@ internal static class ParcelBillingCalculator
warehouseName,
conditionContext,
declaredSum,
declaredCurrency).Select(x => x.Fee).ToList();
declaredCurrency,
remoteZoneKeys).Select(x => x.Fee).ToList();
public static List<BillingFeeLine> CalculateQuoteReceivableFeeLines(
SalesProductPricePublishSnapshotDto priceSnapshot,
@ -422,7 +444,8 @@ internal static class ParcelBillingCalculator
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = 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,
@ -440,7 +463,8 @@ internal static class ParcelBillingCalculator
warehouseName,
conditionContext,
declaredSum,
declaredCurrency);
declaredCurrency,
remoteZoneKeys);
}
public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
@ -656,7 +680,8 @@ internal static class ParcelBillingCalculator
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = 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,
@ -674,7 +699,8 @@ internal static class ParcelBillingCalculator
warehouseName,
conditionContext,
declaredSum,
declaredCurrency);
declaredCurrency,
remoteZoneKeys);
}
/// <summary>
@ -692,7 +718,8 @@ internal static class ParcelBillingCalculator
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null)
string? declaredCurrency = null,
IReadOnlyList<string>? remoteZoneKeys = null)
{
var items = prices.Select(p => new PayableBillingPriceItem(
p.Status, p.CostItem, p.Currency, p.Details,
@ -701,7 +728,13 @@ internal static class ParcelBillingCalculator
items,
priceVersion,
priceEffectiveTime,
new PriceDetailMatchContext(zoneKeys, cargoAttribute, chargeableWeight, warehouseId, warehouseName),
new PriceDetailMatchContext(
zoneKeys,
cargoAttribute,
chargeableWeight,
warehouseId,
warehouseName,
remoteZoneKeys),
chargingStage,
conditionContext,
declaredSum,
@ -899,7 +932,8 @@ internal static class ParcelBillingCalculator
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = null) =>
string? declaredCurrency = null,
IReadOnlyList<string>? remoteZoneKeys = null) =>
CalculateReceivableFeeLines(
priceItems,
priceVersion,
@ -913,7 +947,8 @@ internal static class ParcelBillingCalculator
warehouseName,
conditionContext,
declaredSum,
declaredCurrency).Select(x => x.Fee).ToList();
declaredCurrency,
remoteZoneKeys).Select(x => x.Fee).ToList();
private static List<BillingFeeLine> CalculateReceivableFeeLines(
IEnumerable<ReceivableBillingPriceItem> priceItems,
@ -928,7 +963,8 @@ internal static class ParcelBillingCalculator
string? warehouseName = null,
OrderRuleEvaluationContext? conditionContext = null,
decimal? declaredSum = null,
string? declaredCurrency = 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)>();
@ -951,7 +987,8 @@ internal static class ParcelBillingCalculator
cargoAttribute,
chargeableWeight,
warehouseId,
warehouseName);
warehouseName,
remoteZoneKeys);
if (detail == null)
{
continue;
@ -1084,7 +1121,8 @@ internal static class ParcelBillingCalculator
match.CargoAttribute,
match.ChargeableWeight,
match.WarehouseId,
match.WarehouseName);
match.WarehouseName,
match.RemoteZoneKeys);
if (detail == null)
{
continue;
@ -1269,6 +1307,7 @@ internal static class ParcelBillingCalculator
{
var factors = ParseFactorDictionary(candidate.FactorValuesJson);
if (!ZoneMatches(factors, match.ZoneKeys)
|| !RemoteZoneMatches(factors, match.RemoteZoneKeys)
|| !MatchCargoTypeFactor(factors, match.CargoAttribute)
|| !MatchWarehouseFactor(factors, match.WarehouseId, match.WarehouseName))
{
@ -1343,7 +1382,8 @@ internal static class ParcelBillingCalculator
string cargoAttribute,
decimal chargeableWeight,
int? warehouseId = null,
string? warehouseName = null)
string? warehouseName = null,
IReadOnlyList<string>? remoteZoneKeys = null)
{
ChannelPriceDetailOutput? bestMatch = null;
var bestIntervalWidth = decimal.MaxValue;
@ -1356,6 +1396,11 @@ internal static class ParcelBillingCalculator
continue;
}
if (!RemoteZoneMatches(factors, remoteZoneKeys))
{
continue;
}
if (!MatchCargoTypeFactor(factors, cargoAttribute))
{
continue;
@ -1446,6 +1491,24 @@ internal static class ParcelBillingCalculator
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"))
@ -1457,6 +1520,17 @@ internal static class ParcelBillingCalculator
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);
@ -1810,6 +1884,7 @@ internal static class ParcelBillingCalculator
continue;
}
// 续重邻档总价查找不按偏远分区过滤:邻档通常与运费同组,偏远费单独成项。
if (NormalizeCalculationFormula(candidate.CalculationFormula) != "total")
{
continue;
@ -1933,6 +2008,12 @@ internal static class ParcelBillingCalculator
return null;
}
// 「全部客户」优先:任意价区键共用这一列
if (TryGetMapPrice(map, out var allPrice, "全部客户", "all"))
{
return allPrice;
}
if (!string.IsNullOrWhiteSpace(priceTierKey)
&& map.TryGetValue(priceTierKey.Trim(), out var tierPrice))
{
@ -1947,6 +2028,32 @@ internal static class ParcelBillingCalculator
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))

View File

@ -557,5 +557,63 @@ public class ParcelBillingCalculatorTests
fees[0].Amount.Should().Be(12M);
}
[Fact]
public void CalculateReceivableFees_AllCustomerColumn_ShouldApplyToAnyPriceTierKey()
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem(
1,
"freight",
ParcelBillingTestData.AllCustomerTierDetail(Zone, 33M)));
var fees = ParcelBillingCalculator.CalculateReceivableFees(
snapshot,
_meta.PriceVersion,
_meta.EffectiveTime,
1.2M,
new[] { Zone },
"general",
"VIP5",
OrderFeeChargingStageCodes.Forecast);
fees.Should().ContainSingle();
fees[0].Amount.Should().Be(33M);
}
[Fact]
public void CalculateReceivableFees_RemoteZoneFactor_ShouldRequireMatchingRemoteKeys()
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem(
1,
"remoteFee",
ParcelBillingTestData.RemoteZoneFixedDetail(15M, Zone, "88")));
var miss = ParcelBillingCalculator.CalculateReceivableFees(
snapshot,
_meta.PriceVersion,
_meta.EffectiveTime,
1.2M,
new[] { Zone },
"general",
null,
OrderFeeChargingStageCodes.Forecast,
remoteZoneKeys: Array.Empty<string>());
miss.Should().BeEmpty();
var hit = ParcelBillingCalculator.CalculateReceivableFees(
snapshot,
_meta.PriceVersion,
_meta.EffectiveTime,
1.2M,
new[] { Zone },
"general",
null,
OrderFeeChargingStageCodes.Forecast,
remoteZoneKeys: ParcelBillingCalculator.BuildRemoteZoneKeys(88));
hit.Should().ContainSingle();
hit[0].Amount.Should().Be(15M);
}
#endregion
}

View File

@ -150,6 +150,37 @@ internal static class ParcelBillingTestData
CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = normalCustomerPrice }
};
public static ChannelPriceDetailOutput AllCustomerTierDetail(
string zone,
decimal allCustomerPrice,
string? weightInterval = null) =>
new()
{
CalculationFormula = "total",
CalculatedValue = 0,
FactorValuesJson = new Dictionary<string, string>
{
["价格分区"] = zone,
["重量"] = weightInterval ?? "999"
},
CustomerGroupValues = new Dictionary<string, decimal> { ["全部客户"] = allCustomerPrice }
};
public static ChannelPriceDetailOutput RemoteZoneFixedDetail(
decimal amount,
string priceZone,
string remoteZoneId) =>
new()
{
CalculationFormula = "固定价",
CalculatedValue = amount,
FactorValuesJson = new Dictionary<string, string>
{
["价格分区"] = priceZone,
["偏远分区"] = remoteZoneId
}
};
public static WeightRuleInfo GwRule(string condition = "gw>=vw", string factor = "6000") =>
new()
{