修复订单隔离和查价

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

View File

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

View File

@ -147,12 +147,52 @@ internal static class ParcelBillingCalculator
return area?.Name; 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, SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion, string priceVersion,
DateTime priceEffectiveTime, DateTime priceEffectiveTime,
decimal chargeableWeight, decimal chargeableWeight,
string zoneName, IReadOnlyList<string> zoneKeys,
string cargoAttribute, string cargoAttribute,
CustomerGroup? customerGroup) CustomerGroup? customerGroup)
{ {
@ -164,9 +204,53 @@ internal static class ParcelBillingCalculator
priceVersion, priceVersion,
priceEffectiveTime, priceEffectiveTime,
chargeableWeight, 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, cargoAttribute,
customerGroup); 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> /// <summary>
@ -189,9 +273,10 @@ internal static class ParcelBillingCalculator
priceVersion, priceVersion,
priceEffectiveTime, priceEffectiveTime,
chargeableWeight, chargeableWeight,
zoneName, new[] { zoneName },
cargoAttribute, cargoAttribute,
customerGroup); customerGroup,
onlyForecastChargingTime: true);
} }
public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
@ -294,19 +379,20 @@ internal static class ParcelBillingCalculator
string priceVersion, string priceVersion,
DateTime priceEffectiveTime, DateTime priceEffectiveTime,
decimal chargeableWeight, decimal chargeableWeight,
string zoneName, IReadOnlyList<string> zoneKeys,
string cargoAttribute, string cargoAttribute,
CustomerGroup? customerGroup) CustomerGroup? customerGroup,
bool onlyForecastChargingTime)
{ {
var fees = new List<OrderFee>(); var fees = new List<OrderFee>();
foreach (var priceItem in priceItems.Where(x => x.Status == 1)) foreach (var priceItem in priceItems.Where(x => x.Status == 1))
{ {
if (!IsForecastChargingTime(priceItem.ChargingTime)) if (onlyForecastChargingTime && !IsForecastChargingTime(priceItem.ChargingTime))
{ {
continue; continue;
} }
var detail = MatchPriceDetail(priceItem.Details, zoneName, cargoAttribute, chargeableWeight); var detail = MatchPriceDetail(priceItem.Details, zoneKeys, cargoAttribute, chargeableWeight);
if (detail == null) if (detail == null)
{ {
continue; continue;
@ -343,16 +429,19 @@ internal static class ParcelBillingCalculator
private static ChannelPriceDetailOutput? MatchPriceDetail( private static ChannelPriceDetailOutput? MatchPriceDetail(
IReadOnlyList<ChannelPriceDetailOutput> details, IReadOnlyList<ChannelPriceDetailOutput> details,
string zoneName, IReadOnlyList<string> zoneKeys,
string cargoAttribute, string cargoAttribute,
decimal chargeableWeight) decimal chargeableWeight)
{ {
var cargoLabel = GetCargoAttributeLabel(cargoAttribute); var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
var tierCandidates = new List<(ChannelPriceDetailOutput Detail, decimal Tier)>();
ChannelPriceDetailOutput? rangeHit = null;
ChannelPriceDetailOutput? genericHit = null;
foreach (var detail in details) foreach (var detail in details)
{ {
var factors = ParseFactorDictionary(detail.FactorValuesJson); var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (!MatchFactorValue(factors, "分区", zoneName) if (!ZoneMatches(factors, zoneKeys))
&& !MatchFactorValue(factors, "价格分区", zoneName))
{ {
continue; continue;
} }
@ -363,15 +452,107 @@ internal static class ParcelBillingCalculator
continue; continue;
} }
if (!MatchWeightRange(factors, chargeableWeight)) if (TryParseWeightRange(factors, out var rangeStart, out var rangeEnd))
{ {
if (chargeableWeight >= rangeStart && chargeableWeight <= rangeEnd)
{
rangeHit = detail;
}
continue; 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) 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) private static bool MatchWeightRange(Dictionary<string, string> factors, decimal chargeableWeight)
{ {
if (!factors.TryGetValue("重量", out var rangeText) || string.IsNullOrWhiteSpace(rangeText)) if (!TryParseWeightRange(factors, out var start, out var end))
{
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))
{ {
return true; return true;
} }
@ -434,7 +599,7 @@ internal static class ParcelBillingCalculator
CustomerGroup? customerGroup) CustomerGroup? customerGroup)
{ {
var formula = detail.CalculationFormula?.Trim() ?? string.Empty; 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; return ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0;
} }
@ -513,7 +678,7 @@ internal static class ParcelBillingCalculator
private static decimal? ResolveCustomerGroupPrice(ChannelPriceDetailOutput detail, CustomerGroup? customerGroup) private static decimal? ResolveCustomerGroupPrice(ChannelPriceDetailOutput detail, CustomerGroup? customerGroup)
{ {
if (!customerGroup.HasValue || detail.CustomerGroupValues == null) if (detail.CustomerGroupValues == null)
{ {
return null; return null;
} }
@ -525,18 +690,28 @@ internal static class ParcelBillingCalculator
return null; return null;
} }
var key = customerGroup.Value switch if (customerGroup.HasValue)
{ {
CustomerGroup.Vip1 => "客户组1", var key = customerGroup.Value switch
CustomerGroup.Vip2 => "客户组2", {
CustomerGroup.Vip3 => "客户组3", CustomerGroup.Normal => "普通客户",
CustomerGroup.Vip4 => "客户组4", CustomerGroup.Vip1 => "客户组1",
CustomerGroup.Vip5 => "客户组5", CustomerGroup.Vip2 => "客户组2",
_ => null CustomerGroup.Vip3 => "客户组3",
}; CustomerGroup.Vip4 => "客户组4",
if (key != null && map.TryGetValue(key, out var value)) 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; return null;

View File

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

View File

@ -164,6 +164,54 @@ public class ParcelBillingCalculatorTests
fees[0].Amount.Should().Be(15M); 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] [Fact]
public void HasEnabledForecastPriceItems_ShouldDetectForecastEnabledItems() public void HasEnabledForecastPriceItems_ShouldDetectForecastEnabledItems()
{ {

View File

@ -92,6 +92,22 @@ internal static class ParcelBillingTestData
FactorValuesJson = new Dictionary<string, string> { ["分区"] = zone } 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") => public static WeightRuleInfo GwRule(string condition = "gw>=vw", string factor = "6000") =>
new() new()
{ {