修复订单隔离和查价

This commit is contained in:
zzh 2026-06-25 19:39:07 +08:00
parent 00835bfadd
commit b7ed14d6af
6 changed files with 305 additions and 59 deletions

View File

@ -84,13 +84,13 @@ public partial class OrderService
SyncWeightRows(weights, forecast);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
var zoneName = await MatchPriceZoneAsync(
var zoneKeys = await ResolvePriceZoneKeysAsync(
product,
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode);
if (string.IsNullOrWhiteSpace(zoneName))
if (zoneKeys == null || zoneKeys.Count == 0)
{
throw new BusinessException("价格分区未匹配(规则快照)");
}
@ -101,7 +101,7 @@ public partial class OrderService
snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime,
forecast.ChargeableWeight ?? 0,
zoneName,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup);
@ -398,7 +398,7 @@ public partial class OrderService
zipCode));
}
private async Task<string?> MatchPriceZoneAsync(
private async Task<IReadOnlyList<string>?> ResolvePriceZoneKeysAsync(
SalesProduct product,
string countryCode,
string? province,
@ -407,10 +407,15 @@ public partial class OrderService
{
if (!product.PriceAreaId.HasValue || product.PriceAreaId <= 0)
{
return "默认分区";
return new[] { "默认分区" };
}
var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == product.PriceAreaId.Value);
if (area == null)
{
return null;
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.PriceAreaId.Value);
var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched(
rule,
@ -418,7 +423,8 @@ public partial class OrderService
province,
city,
zipCode));
return ParcelBillingCalculator.ResolveZoneName(matched, area);
var keys = ParcelBillingCalculator.BuildPriceZoneKeys(matched, area);
return keys.Count > 0 ? keys : null;
}
private async Task ApplyChargeableWeightAsync(SalesProduct product, OrderWeight forecast)

View File

@ -148,27 +148,27 @@ public partial class OrderService
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
await ApplyChargeableWeightAsync(product, forecast);
var zoneName = await MatchPriceZoneAsync(
var zoneKeys = await ResolvePriceZoneKeysAsync(
product,
context.CountryCode,
context.Province,
context.City,
context.PostalCode);
if (string.IsNullOrWhiteSpace(zoneName))
if (zoneKeys == null || zoneKeys.Count == 0)
{
return null;
}
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
snapshotBinding.Price,
snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime,
forecast.ChargeableWeight ?? 0,
zoneName,
zoneKeys,
context.CargoAttribute,
customerGroup);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshotBinding.Price))
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
{
return null;
}
@ -189,7 +189,7 @@ public partial class OrderService
PriceVersion = snapshotBinding.PriceVersion,
VolumetricWeight = forecast.VolumetricWeight ?? 0,
ChargeableWeight = forecast.ChargeableWeight ?? 0,
ZoneName = zoneName,
ZoneName = zoneKeys[0],
IsRemote = isRemote,
Fees = fees
};

View File

@ -147,12 +147,52 @@ internal static class ParcelBillingCalculator
return area?.Name;
}
public static List<OrderFee> CalculateForecastReceivableFees(
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 List<OrderFee> CalculateQuoteReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
string zoneName,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup)
{
@ -164,9 +204,53 @@ internal static class ParcelBillingCalculator
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneName,
zoneKeys,
cargoAttribute,
customerGroup,
onlyForecastChargingTime: false);
}
public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
priceSnapshot.Prices.Any(x => x.Status == 1);
public static List<OrderFee> CalculateForecastReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
string zoneName,
string cargoAttribute,
CustomerGroup? customerGroup) =>
CalculateForecastReceivableFees(
priceSnapshot,
priceVersion,
priceEffectiveTime,
chargeableWeight,
new[] { zoneName },
cargoAttribute,
customerGroup);
public static List<OrderFee> CalculateForecastReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup)
{
var items = priceSnapshot.Prices.Select(p => new BillingPriceItem(
p.Status, p.ChargingTime, p.CostItem, p.Currency, p.Details));
return CalculateForecastFees(
items,
OrderFeeTypeCodes.Receivable,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
customerGroup,
onlyForecastChargingTime: true);
}
/// <summary>
@ -189,9 +273,10 @@ internal static class ParcelBillingCalculator
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneName,
new[] { zoneName },
cargoAttribute,
customerGroup);
customerGroup,
onlyForecastChargingTime: true);
}
public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
@ -294,19 +379,20 @@ internal static class ParcelBillingCalculator
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
string zoneName,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup)
CustomerGroup? customerGroup,
bool onlyForecastChargingTime)
{
var fees = new List<OrderFee>();
foreach (var priceItem in priceItems.Where(x => x.Status == 1))
{
if (!IsForecastChargingTime(priceItem.ChargingTime))
if (onlyForecastChargingTime && !IsForecastChargingTime(priceItem.ChargingTime))
{
continue;
}
var detail = MatchPriceDetail(priceItem.Details, zoneName, cargoAttribute, chargeableWeight);
var detail = MatchPriceDetail(priceItem.Details, zoneKeys, cargoAttribute, chargeableWeight);
if (detail == null)
{
continue;
@ -343,16 +429,19 @@ internal static class ParcelBillingCalculator
private static ChannelPriceDetailOutput? MatchPriceDetail(
IReadOnlyList<ChannelPriceDetailOutput> details,
string zoneName,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
decimal chargeableWeight)
{
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
var tierCandidates = new List<(ChannelPriceDetailOutput Detail, decimal Tier)>();
ChannelPriceDetailOutput? rangeHit = null;
ChannelPriceDetailOutput? genericHit = null;
foreach (var detail in details)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (!MatchFactorValue(factors, "分区", zoneName)
&& !MatchFactorValue(factors, "价格分区", zoneName))
if (!ZoneMatches(factors, zoneKeys))
{
continue;
}
@ -363,15 +452,107 @@ internal static class ParcelBillingCalculator
continue;
}
if (!MatchWeightRange(factors, chargeableWeight))
if (TryParseWeightRange(factors, out var rangeStart, out var rangeEnd))
{
if (chargeableWeight >= rangeStart && chargeableWeight <= rangeEnd)
{
rangeHit = detail;
}
continue;
}
return detail;
if (TryParseSingleWeightTier(factors, out var tier))
{
tierCandidates.Add((detail, tier));
continue;
}
genericHit = detail;
}
return null;
if (rangeHit != null)
{
return rangeHit;
}
if (tierCandidates.Count > 0)
{
var ceiling = tierCandidates
.Where(x => x.Tier >= chargeableWeight)
.OrderBy(x => x.Tier)
.Select(x => x.Detail)
.FirstOrDefault();
if (ceiling != null)
{
return ceiling;
}
return tierCandidates.OrderByDescending(x => x.Tier).First().Detail;
}
return genericHit;
}
private static bool ZoneMatches(Dictionary<string, string> factors, IReadOnlyList<string> zoneKeys)
{
if (!factors.TryGetValue("分区", out var zoneValue) || string.IsNullOrWhiteSpace(zoneValue))
{
if (!factors.TryGetValue("价格分区", out zoneValue) || string.IsNullOrWhiteSpace(zoneValue))
{
return true;
}
}
var expected = zoneValue.Trim();
return zoneKeys.Any(key => string.Equals(key.Trim(), expected, StringComparison.OrdinalIgnoreCase));
}
private static bool TryParseWeightRange(
Dictionary<string, string> factors,
out decimal start,
out decimal end)
{
start = 0;
end = 0;
if (!factors.TryGetValue("重量", out var rangeText) || string.IsNullOrWhiteSpace(rangeText))
{
return false;
}
var parts = rangeText.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
{
return false;
}
if (!decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out start))
{
return false;
}
if (!decimal.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out end))
{
return false;
}
return true;
}
private static bool TryParseSingleWeightTier(Dictionary<string, string> factors, out decimal tier)
{
tier = 0;
if (!factors.TryGetValue("重量", out var text) || string.IsNullOrWhiteSpace(text))
{
return false;
}
if (text.Contains('-', StringComparison.Ordinal))
{
return false;
}
return decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out tier);
}
private static Dictionary<string, string> ParseFactorDictionary(object? factorValuesJson)
@ -404,23 +585,7 @@ internal static class ParcelBillingCalculator
private static bool MatchWeightRange(Dictionary<string, string> factors, decimal chargeableWeight)
{
if (!factors.TryGetValue("重量", out var rangeText) || string.IsNullOrWhiteSpace(rangeText))
{
return true;
}
var parts = rangeText.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
{
return true;
}
if (!decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var start))
{
return true;
}
if (!decimal.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var end))
if (!TryParseWeightRange(factors, out var start, out var end))
{
return true;
}
@ -434,7 +599,7 @@ internal static class ParcelBillingCalculator
CustomerGroup? customerGroup)
{
var formula = detail.CalculationFormula?.Trim() ?? string.Empty;
if (formula is "单价" or "固定价")
if (formula is "单价" or "固定价" or "total" or "总价")
{
return ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0;
}
@ -513,7 +678,7 @@ internal static class ParcelBillingCalculator
private static decimal? ResolveCustomerGroupPrice(ChannelPriceDetailOutput detail, CustomerGroup? customerGroup)
{
if (!customerGroup.HasValue || detail.CustomerGroupValues == null)
if (detail.CustomerGroupValues == null)
{
return null;
}
@ -525,18 +690,28 @@ internal static class ParcelBillingCalculator
return null;
}
var key = customerGroup.Value switch
if (customerGroup.HasValue)
{
CustomerGroup.Vip1 => "客户组1",
CustomerGroup.Vip2 => "客户组2",
CustomerGroup.Vip3 => "客户组3",
CustomerGroup.Vip4 => "客户组4",
CustomerGroup.Vip5 => "客户组5",
_ => null
};
if (key != null && map.TryGetValue(key, out var value))
var key = customerGroup.Value switch
{
CustomerGroup.Normal => "普通客户",
CustomerGroup.Vip1 => "客户组1",
CustomerGroup.Vip2 => "客户组2",
CustomerGroup.Vip3 => "客户组3",
CustomerGroup.Vip4 => "客户组4",
CustomerGroup.Vip5 => "客户组5",
_ => null
};
if (key != null && map.TryGetValue(key, out var value))
{
return value;
}
}
if (map.TryGetValue("普通客户", out var normalPrice))
{
return value;
return normalPrice;
}
return null;

View File

@ -67,10 +67,11 @@ public class ParcelBillingCalculatorDemoTests
var kpiArea = new KpiArea { Id = 1, Name = "美西分区" };
var zoneMatched = ParcelBillingCalculator.IsAreaMatched(
kpiRule, quoteContext.CountryCode, quoteContext.Province, quoteContext.City, quoteContext.PostalCode);
var zoneKeys = ParcelBillingCalculator.BuildPriceZoneKeys(kpiRule, kpiArea);
var zoneName = ParcelBillingCalculator.ResolveZoneName(kpiRule, kpiArea) ?? "美西";
zoneMatched.Should().BeTrue($"邮编 {quoteContext.PostalCode} 应命中美西分区");
_output.WriteLine($"分区匹配: {zoneMatched}, 分区名: {zoneName}");
_output.WriteLine($"分区匹配: {zoneMatched}, 分区名: {zoneName}, 分区键: {string.Join(',', zoneKeys)}");
// —— 3. 计重(可在 ParcelBillingCalculator.CalculateRawBillableWeight 打断点)——
var forecastWeight = ParcelBillingCalculator.BuildForecastWeight(quoteContext);
@ -99,7 +100,7 @@ public class ParcelBillingCalculatorDemoTests
priceVersion,
effectiveTime,
chargeableWeight,
zoneName,
new[] { "美西" },
quoteContext.CargoAttribute,
null);
@ -127,7 +128,7 @@ public class ParcelBillingCalculatorDemoTests
priceVersion,
effectiveTime,
chargeableWeight,
zoneName,
"美西",
quoteContext.CargoAttribute,
null);

View File

@ -164,6 +164,54 @@ public class ParcelBillingCalculatorTests
fees[0].Amount.Should().Be(15M);
}
[Fact]
public void CalculateQuoteReceivableFees_ShouldSupportOutboundTotalPriceTiers()
{
var details = new[]
{
ParcelBillingTestData.TotalTierDetail("3", 0.5M, 11M),
ParcelBillingTestData.TotalTierDetail("3", 1M, 12M),
ParcelBillingTestData.TotalTierDetail("3", 1.5M, 12M),
ParcelBillingTestData.TotalTierDetail("3", 2M, 14M),
};
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem("outbound", 1, "freight", details));
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
snapshot,
_meta.PriceVersion,
_meta.EffectiveTime,
1M,
new[] { "3" },
"general",
null);
fees.Should().ContainSingle();
fees[0].Amount.Should().Be(12M);
}
[Fact]
public void CalculateForecastReceivableFees_ShouldApplyNormalCustomerLabel()
{
var detail = ParcelBillingTestData.TotalTierDetail("3", 1M, 99M);
detail.CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = 12M };
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", detail));
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshot,
_meta.PriceVersion,
_meta.EffectiveTime,
1M,
new[] { "3" },
"general",
CustomerGroup.Normal);
fees.Should().ContainSingle();
fees[0].Amount.Should().Be(12M);
}
[Fact]
public void HasEnabledForecastPriceItems_ShouldDetectForecastEnabledItems()
{

View File

@ -92,6 +92,22 @@ internal static class ParcelBillingTestData
FactorValuesJson = new Dictionary<string, string> { ["分区"] = zone }
};
public static ChannelPriceDetailOutput TotalTierDetail(
string zone,
decimal weightTier,
decimal normalCustomerPrice) =>
new()
{
CalculationFormula = "total",
CalculatedValue = 0,
FactorValuesJson = new Dictionary<string, string>
{
["分区"] = zone,
["重量"] = weightTier.ToString(CultureInfo.InvariantCulture)
},
CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = normalCustomerPrice }
};
public static WeightRuleInfo GwRule(string condition = "gw>=vw", string factor = "6000") =>
new()
{