feat: 规则快照固化计重/分区规则并修正卖价公式
规则发布写入黑名单、偏远、计重规则与进位明细;查价/下单优先读快照。修正 unit/total/continue 算价及续单价相邻档位总价基数。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8aa6ceb677
commit
2c69e8e5b8
@ -47,6 +47,66 @@ public class SalesProductRulesPublishSnapshotDto
|
||||
/// 分配的服务商列表。
|
||||
/// </summary>
|
||||
public List<SalesProductAllocationProviderOutput> Providers { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 黑名单分区 ID(发布时固化)。
|
||||
/// </summary>
|
||||
public int? BlacklistAreaId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 黑名单分区名称。
|
||||
/// </summary>
|
||||
public string? BlacklistAreaName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 黑名单分区明细,发布时从 t_kpi_area_info 固化。
|
||||
/// </summary>
|
||||
public List<KpiAreaInfoOutput> BlacklistAreaInfos { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 偏远分区 ID(发布时固化)。
|
||||
/// </summary>
|
||||
public int? RemoteAreaId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 偏远分区名称。
|
||||
/// </summary>
|
||||
public string? RemoteAreaName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 偏远分区明细,发布时从 t_kpi_area_info 固化。
|
||||
/// </summary>
|
||||
public List<KpiAreaInfoOutput> RemoteAreaInfos { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 计重规则 ID(发布时固化)。
|
||||
/// </summary>
|
||||
public int? WeightRuleId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 计重规则名称。
|
||||
/// </summary>
|
||||
public string? WeightRuleName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 计重规则明细,发布时从 t_weight_rule_info 固化。
|
||||
/// </summary>
|
||||
public List<WeightRuleInfoOutput> WeightRuleInfos { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 计重进位 ID(发布时固化)。
|
||||
/// </summary>
|
||||
public int? WeightRoundingId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 计重进位名称。
|
||||
/// </summary>
|
||||
public string? WeightRoundingName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 计重进位区间,发布时从 t_weight_rounding_info 固化。
|
||||
/// </summary>
|
||||
public List<WeightRoundingInfoOutput> WeightRoundingInfos { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -174,7 +174,7 @@ public partial class SalesProductService
|
||||
}
|
||||
else
|
||||
{
|
||||
snapshot = new SalesProductRulesPublishSnapshot
|
||||
var rulesSnapshot = new SalesProductRulesPublishSnapshot
|
||||
{
|
||||
Product = new SalesProductSaveInput
|
||||
{
|
||||
@ -197,6 +197,13 @@ public partial class SalesProductService
|
||||
},
|
||||
Providers = (await GetAllocationCoreAsync(salesProductId)).Providers
|
||||
};
|
||||
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
|
||||
rulesSnapshot,
|
||||
product,
|
||||
KpiAreaRepository,
|
||||
WeightRuleRepository,
|
||||
WeightRoundingRepository);
|
||||
snapshot = rulesSnapshot;
|
||||
}
|
||||
|
||||
// Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。
|
||||
@ -434,10 +441,7 @@ public partial class SalesProductService
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class SalesProductRulesPublishSnapshot
|
||||
private sealed class SalesProductRulesPublishSnapshot : SalesProductRulesPublishSnapshotDto
|
||||
{
|
||||
public SalesProductSaveInput Product { get; set; } = new();
|
||||
|
||||
public List<SalesProductAllocationProviderOutput> Providers { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@ public partial class SalesProductService : ApplicationService
|
||||
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
|
||||
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
|
||||
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
||||
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
|
||||
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
|
||||
|
||||
/// <summary>
|
||||
/// 查询销售产品列表。
|
||||
|
||||
@ -57,6 +57,7 @@ public partial class OrderService
|
||||
|
||||
if (await IsBlacklistHitAsync(
|
||||
product,
|
||||
snapshotBinding.Rules,
|
||||
country.CountryCode,
|
||||
input.Receiver?.Province,
|
||||
input.Receiver?.City,
|
||||
@ -76,13 +77,14 @@ public partial class OrderService
|
||||
{
|
||||
order.IsRemote = await IsRemoteHitAsync(
|
||||
product,
|
||||
snapshotBinding.Rules,
|
||||
country.CountryCode,
|
||||
input.Receiver?.Province,
|
||||
input.Receiver?.City,
|
||||
input.Receiver?.ZipCode) ? (byte)1 : (byte)0;
|
||||
|
||||
var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast);
|
||||
await ApplyChargeableWeightAsync(product, forecast);
|
||||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
|
||||
SyncWeightRows(weights, forecast);
|
||||
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
||||
|
||||
@ -287,9 +289,9 @@ public partial class OrderService
|
||||
};
|
||||
}
|
||||
|
||||
private Task<SalesProductRulesPublishSnapshotDto> BuildLiveRulesSnapshotAsync(SalesProduct product)
|
||||
private async Task<SalesProductRulesPublishSnapshotDto> BuildLiveRulesSnapshotAsync(SalesProduct product)
|
||||
{
|
||||
return Task.FromResult(new SalesProductRulesPublishSnapshotDto
|
||||
var snapshot = new SalesProductRulesPublishSnapshotDto
|
||||
{
|
||||
Product = new SalesProductSaveInput
|
||||
{
|
||||
@ -304,7 +306,14 @@ public partial class OrderService
|
||||
IsActive = product.IsActive,
|
||||
Scheme = product.Scheme
|
||||
}
|
||||
});
|
||||
};
|
||||
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
|
||||
snapshot,
|
||||
product,
|
||||
KpiAreaRepository,
|
||||
WeightRuleRepository,
|
||||
WeightRoundingRepository);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async Task<SalesProductPricePublishSnapshotDto> BuildLivePriceSnapshotAsync(SalesProduct product)
|
||||
@ -372,17 +381,29 @@ public partial class OrderService
|
||||
|
||||
private async Task<bool> IsBlacklistHitAsync(
|
||||
SalesProduct product,
|
||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||
string countryCode,
|
||||
string? province,
|
||||
string? city,
|
||||
string? zipCode)
|
||||
{
|
||||
if (!product.BlacklistAreaId.HasValue || product.BlacklistAreaId <= 0)
|
||||
var areaId = ResolveEffectiveBlacklistAreaId(product, rulesSnapshot);
|
||||
if (!areaId.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.BlacklistAreaId.Value);
|
||||
if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 })
|
||||
{
|
||||
return RulesSnapshotHelper.IsAreaHitFromSnapshot(
|
||||
rulesSnapshot.BlacklistAreaInfos,
|
||||
countryCode,
|
||||
province,
|
||||
city,
|
||||
zipCode);
|
||||
}
|
||||
|
||||
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
|
||||
return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
|
||||
rule,
|
||||
countryCode,
|
||||
@ -393,17 +414,29 @@ public partial class OrderService
|
||||
|
||||
private async Task<bool> IsRemoteHitAsync(
|
||||
SalesProduct product,
|
||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||
string countryCode,
|
||||
string? province,
|
||||
string? city,
|
||||
string? zipCode)
|
||||
{
|
||||
if (!product.RemoteAreaId.HasValue || product.RemoteAreaId <= 0)
|
||||
var areaId = ResolveEffectiveRemoteAreaId(product, rulesSnapshot);
|
||||
if (!areaId.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.RemoteAreaId.Value);
|
||||
if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 })
|
||||
{
|
||||
return RulesSnapshotHelper.IsAreaHitFromSnapshot(
|
||||
rulesSnapshot.RemoteAreaInfos,
|
||||
countryCode,
|
||||
province,
|
||||
city,
|
||||
zipCode);
|
||||
}
|
||||
|
||||
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
|
||||
return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
|
||||
rule,
|
||||
countryCode,
|
||||
@ -412,6 +445,50 @@ public partial class OrderService
|
||||
zipCode));
|
||||
}
|
||||
|
||||
private static int? ResolveEffectiveBlacklistAreaId(
|
||||
SalesProduct product,
|
||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
|
||||
{
|
||||
if (product.BlacklistAreaId is > 0)
|
||||
{
|
||||
return product.BlacklistAreaId;
|
||||
}
|
||||
|
||||
if (rulesSnapshot?.BlacklistAreaId is > 0)
|
||||
{
|
||||
return rulesSnapshot.BlacklistAreaId;
|
||||
}
|
||||
|
||||
if (rulesSnapshot?.Product?.BlacklistAreaId is > 0)
|
||||
{
|
||||
return rulesSnapshot.Product.BlacklistAreaId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? ResolveEffectiveRemoteAreaId(
|
||||
SalesProduct product,
|
||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
|
||||
{
|
||||
if (product.RemoteAreaId is > 0)
|
||||
{
|
||||
return product.RemoteAreaId;
|
||||
}
|
||||
|
||||
if (rulesSnapshot?.RemoteAreaId is > 0)
|
||||
{
|
||||
return rulesSnapshot.RemoteAreaId;
|
||||
}
|
||||
|
||||
if (rulesSnapshot?.Product?.RemoteAreaId is > 0)
|
||||
{
|
||||
return rulesSnapshot.Product.RemoteAreaId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? ResolveEffectivePriceAreaId(
|
||||
SalesProduct product,
|
||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||
@ -482,37 +559,60 @@ public partial class OrderService
|
||||
return keys.Count > 0 ? keys : null;
|
||||
}
|
||||
|
||||
private async Task ApplyChargeableWeightAsync(SalesProduct product, OrderWeight forecast)
|
||||
private async Task ApplyChargeableWeightAsync(
|
||||
SalesProduct product,
|
||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||
OrderWeight forecast)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!product.WeightRuleId.HasValue || product.WeightRuleId <= 0)
|
||||
var weightRuleId = ResolveEffectiveWeightRuleId(product, rulesSnapshot);
|
||||
if (!weightRuleId.HasValue)
|
||||
{
|
||||
throw new BusinessException("未配置计重规则");
|
||||
}
|
||||
|
||||
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRuleId.Value);
|
||||
if (weightRule == null)
|
||||
IReadOnlyList<WeightRuleInfo> ruleInfos;
|
||||
if (rulesSnapshot?.WeightRuleInfos is { Count: > 0 })
|
||||
{
|
||||
throw new BusinessException("未配置计重规则");
|
||||
ruleInfos = RulesSnapshotHelper.ToWeightRuleEntities(rulesSnapshot.WeightRuleInfos);
|
||||
}
|
||||
else
|
||||
{
|
||||
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value);
|
||||
if (weightRule == null)
|
||||
{
|
||||
throw new BusinessException("未配置计重规则");
|
||||
}
|
||||
|
||||
ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value);
|
||||
}
|
||||
|
||||
var ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value);
|
||||
var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos);
|
||||
forecast.VolumetricWeight = volumetricWeight;
|
||||
|
||||
if (!product.WeightRoundingId.HasValue || product.WeightRoundingId <= 0)
|
||||
var weightRoundingId = ResolveEffectiveWeightRoundingId(product, rulesSnapshot);
|
||||
if (!weightRoundingId.HasValue)
|
||||
{
|
||||
throw new BusinessException("未配置计重进位");
|
||||
}
|
||||
|
||||
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRoundingId.Value);
|
||||
if (rounding == null)
|
||||
IReadOnlyList<WeightRoundingInfo> ranges;
|
||||
if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 })
|
||||
{
|
||||
throw new BusinessException("未配置计重进位");
|
||||
ranges = RulesSnapshotHelper.ToWeightRoundingEntities(rulesSnapshot.WeightRoundingInfos);
|
||||
}
|
||||
else
|
||||
{
|
||||
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId.Value);
|
||||
if (rounding == null)
|
||||
{
|
||||
throw new BusinessException("未配置计重进位");
|
||||
}
|
||||
|
||||
ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
||||
}
|
||||
|
||||
var ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value);
|
||||
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
|
||||
}
|
||||
catch (BusinessException)
|
||||
@ -525,6 +625,50 @@ public partial class OrderService
|
||||
}
|
||||
}
|
||||
|
||||
private static int? ResolveEffectiveWeightRuleId(
|
||||
SalesProduct product,
|
||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
|
||||
{
|
||||
if (product.WeightRuleId is > 0)
|
||||
{
|
||||
return product.WeightRuleId;
|
||||
}
|
||||
|
||||
if (rulesSnapshot?.WeightRuleId is > 0)
|
||||
{
|
||||
return rulesSnapshot.WeightRuleId;
|
||||
}
|
||||
|
||||
if (rulesSnapshot?.Product?.WeightRuleId is > 0)
|
||||
{
|
||||
return rulesSnapshot.Product.WeightRuleId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? ResolveEffectiveWeightRoundingId(
|
||||
SalesProduct product,
|
||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
|
||||
{
|
||||
if (product.WeightRoundingId is > 0)
|
||||
{
|
||||
return product.WeightRoundingId;
|
||||
}
|
||||
|
||||
if (rulesSnapshot?.WeightRoundingId is > 0)
|
||||
{
|
||||
return rulesSnapshot.WeightRoundingId;
|
||||
}
|
||||
|
||||
if (rulesSnapshot?.Product?.WeightRoundingId is > 0)
|
||||
{
|
||||
return rulesSnapshot.Product.WeightRoundingId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<OrderWeight> BuildInitialWeightRows(int orderId, OrderParcelCreateInput input)
|
||||
{
|
||||
var cubic = ParcelBillingCalculator.CalculateCubic(input.Length!.Value, input.Width!.Value, input.Height!.Value);
|
||||
|
||||
@ -196,6 +196,7 @@ public partial class OrderService
|
||||
|
||||
if (await IsBlacklistHitAsync(
|
||||
product,
|
||||
snapshotBinding.Rules,
|
||||
context.CountryCode,
|
||||
context.Province,
|
||||
context.City,
|
||||
@ -210,7 +211,7 @@ public partial class OrderService
|
||||
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
||||
try
|
||||
{
|
||||
await ApplyChargeableWeightAsync(product, forecast);
|
||||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
@ -281,6 +282,7 @@ public partial class OrderService
|
||||
|
||||
var isRemote = await IsRemoteHitAsync(
|
||||
product,
|
||||
snapshotBinding.Rules,
|
||||
context.CountryCode,
|
||||
context.Province,
|
||||
context.City,
|
||||
|
||||
@ -631,7 +631,13 @@ internal static class ParcelBillingCalculator
|
||||
continue;
|
||||
}
|
||||
|
||||
var amount = CalculateDetailAmount(detail, chargeableWeight, customerGroup);
|
||||
var amount = CalculateDetailAmount(
|
||||
priceItem.Details,
|
||||
detail,
|
||||
zoneKeys,
|
||||
cargoAttribute,
|
||||
chargeableWeight,
|
||||
customerGroup);
|
||||
fees.Add(new OrderFee
|
||||
{
|
||||
Type = feeType,
|
||||
@ -835,16 +841,119 @@ internal static class ParcelBillingCalculator
|
||||
}
|
||||
|
||||
private static decimal CalculateDetailAmount(
|
||||
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
|
||||
ChannelPriceDetailOutput detail,
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
string cargoAttribute,
|
||||
decimal chargeableWeight,
|
||||
CustomerGroup? customerGroup)
|
||||
{
|
||||
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
|
||||
return NormalizeCalculationFormula(detail.CalculationFormula) switch
|
||||
{
|
||||
"unit" => CalculateUnitPrice(detail, chargeableWeight, customerGroup),
|
||||
"total" => ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0,
|
||||
"continue" => CalculateContinueAmount(
|
||||
allDetails,
|
||||
detail,
|
||||
zoneKeys,
|
||||
cargoLabel,
|
||||
chargeableWeight,
|
||||
customerGroup),
|
||||
_ => detail.CalculatedValue ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单价:计费重 × 单价。
|
||||
/// </summary>
|
||||
private static decimal CalculateUnitPrice(
|
||||
ChannelPriceDetailOutput detail,
|
||||
decimal chargeableWeight,
|
||||
CustomerGroup? customerGroup)
|
||||
{
|
||||
return NormalizeCalculationFormula(detail.CalculationFormula) switch
|
||||
var unitPrice = ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0;
|
||||
return Math.Round(chargeableWeight * unitPrice, 4, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 续单价:优先「下档总价 + (计费重-区间起点)×续单价」;否则回退单行 continuePricing(首重/续重进位)。
|
||||
/// </summary>
|
||||
private static decimal CalculateContinueAmount(
|
||||
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
|
||||
ChannelPriceDetailOutput detail,
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
string cargoLabel,
|
||||
decimal chargeableWeight,
|
||||
CustomerGroup? customerGroup)
|
||||
{
|
||||
var factors = ParseFactorDictionary(detail.FactorValuesJson);
|
||||
if (TryGetWeightFactorText(factors, out var weightText)
|
||||
&& TryParseWeightInterval(weightText, out var rangeStart, out _)
|
||||
&& TryFindAdjacentTotalBasePrice(
|
||||
allDetails,
|
||||
zoneKeys,
|
||||
cargoLabel,
|
||||
rangeStart,
|
||||
customerGroup,
|
||||
out var basePrice))
|
||||
{
|
||||
"unit" or "total" => ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0,
|
||||
"continue" => CalculateContinuePrice(detail, chargeableWeight, customerGroup),
|
||||
_ => detail.CalculatedValue ?? 0
|
||||
};
|
||||
var continueUnitPrice = ResolveCustomerGroupPrice(detail, customerGroup) ?? 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, customerGroup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找与续单价区间左端点衔接的下一档总价(如 (0,0.5] 总价 10 对应 (0.5,1] 续单价)。
|
||||
/// </summary>
|
||||
private static bool TryFindAdjacentTotalBasePrice(
|
||||
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
|
||||
IReadOnlyList<string> zoneKeys,
|
||||
string cargoLabel,
|
||||
decimal continueRangeStart,
|
||||
CustomerGroup? customerGroup,
|
||||
out decimal basePrice)
|
||||
{
|
||||
basePrice = 0;
|
||||
foreach (var candidate in allDetails)
|
||||
{
|
||||
var factors = ParseFactorDictionary(candidate.FactorValuesJson);
|
||||
if (!ZoneMatches(factors, zoneKeys) || !MatchCargoTypeFactor(factors, cargoLabel))
|
||||
{
|
||||
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 = ResolveCustomerGroupPrice(candidate, customerGroup) ?? candidate.CalculatedValue ?? 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
220
src/Oms.Application/Shared/RulesSnapshotHelper.cs
Normal file
220
src/Oms.Application/Shared/RulesSnapshotHelper.cs
Normal file
@ -0,0 +1,220 @@
|
||||
using Oms.Application.Dtos.Channels;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Services.Orders;
|
||||
using Oms.Domain.ChannelsAggregate.Entities;
|
||||
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 将黑名单/偏远/计重/进位规则固化进销售产品规则发布快照。
|
||||
/// </summary>
|
||||
public static class RulesSnapshotHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// 按产品配置加载 KPI 分区、计重规则、进位区间写入规则快照。
|
||||
/// </summary>
|
||||
public static async Task ApplyRulesToSnapshotAsync(
|
||||
SalesProductRulesPublishSnapshotDto snapshot,
|
||||
SalesProduct product,
|
||||
IKpiAreaRepository kpiAreaRepository,
|
||||
IWeightRuleRepository weightRuleRepository,
|
||||
IWeightRoundingRepository weightRoundingRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ApplyKpiAreaToSnapshotAsync(
|
||||
snapshot,
|
||||
product.BlacklistAreaId,
|
||||
kpiAreaRepository,
|
||||
(s, areaId, areaName, infos) =>
|
||||
{
|
||||
s.BlacklistAreaId = areaId;
|
||||
s.BlacklistAreaName = areaName;
|
||||
s.BlacklistAreaInfos = infos;
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
await ApplyKpiAreaToSnapshotAsync(
|
||||
snapshot,
|
||||
product.RemoteAreaId,
|
||||
kpiAreaRepository,
|
||||
(s, areaId, areaName, infos) =>
|
||||
{
|
||||
s.RemoteAreaId = areaId;
|
||||
s.RemoteAreaName = areaName;
|
||||
s.RemoteAreaInfos = infos;
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
await ApplyWeightRuleToSnapshotAsync(snapshot, product, weightRuleRepository, cancellationToken);
|
||||
await ApplyWeightRoundingToSnapshotAsync(snapshot, product, weightRoundingRepository, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从快照内嵌黑名单/偏远明细匹配地址(不查库)。
|
||||
/// </summary>
|
||||
public static bool IsAreaHitFromSnapshot(
|
||||
IReadOnlyList<KpiAreaInfoOutput>? infos,
|
||||
string countryCode,
|
||||
string? province,
|
||||
string? city,
|
||||
string? zipCode)
|
||||
{
|
||||
if (infos == null || infos.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return infos.Any(rule => ParcelBillingCalculator.IsAreaMatched(
|
||||
rule,
|
||||
countryCode,
|
||||
province,
|
||||
city,
|
||||
zipCode));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将快照内嵌计重明细转为实体供计算器使用。
|
||||
/// </summary>
|
||||
public static List<WeightRuleInfo> ToWeightRuleEntities(IReadOnlyList<WeightRuleInfoOutput> infos) =>
|
||||
infos.Select(MapWeightRuleInfo).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// 将快照内嵌进位区间转为实体供计算器使用。
|
||||
/// </summary>
|
||||
public static List<WeightRoundingInfo> ToWeightRoundingEntities(IReadOnlyList<WeightRoundingInfoOutput> infos) =>
|
||||
infos.Select(MapWeightRoundingInfo).ToList();
|
||||
|
||||
private static async Task ApplyKpiAreaToSnapshotAsync(
|
||||
SalesProductRulesPublishSnapshotDto snapshot,
|
||||
int? areaId,
|
||||
IKpiAreaRepository kpiAreaRepository,
|
||||
Action<SalesProductRulesPublishSnapshotDto, int?, string?, List<KpiAreaInfoOutput>> assign,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!areaId.HasValue || areaId.Value <= 0)
|
||||
{
|
||||
assign(snapshot, null, null, []);
|
||||
return;
|
||||
}
|
||||
|
||||
var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId.Value, cancellationToken);
|
||||
var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value, cancellationToken);
|
||||
assign(
|
||||
snapshot,
|
||||
areaId,
|
||||
area?.Name,
|
||||
infos.Select(MapKpiAreaInfo).ToList());
|
||||
}
|
||||
|
||||
private static async Task ApplyWeightRuleToSnapshotAsync(
|
||||
SalesProductRulesPublishSnapshotDto snapshot,
|
||||
SalesProduct product,
|
||||
IWeightRuleRepository weightRuleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!product.WeightRuleId.HasValue || product.WeightRuleId.Value <= 0)
|
||||
{
|
||||
snapshot.WeightRuleId = null;
|
||||
snapshot.WeightRuleName = null;
|
||||
snapshot.WeightRuleInfos = [];
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot.WeightRuleId = product.WeightRuleId;
|
||||
var rule = await weightRuleRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRuleId.Value, cancellationToken);
|
||||
snapshot.WeightRuleName = rule?.Name;
|
||||
var infos = await weightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value, cancellationToken);
|
||||
snapshot.WeightRuleInfos = infos.Select(MapWeightRuleInfoOutput).ToList();
|
||||
}
|
||||
|
||||
private static async Task ApplyWeightRoundingToSnapshotAsync(
|
||||
SalesProductRulesPublishSnapshotDto snapshot,
|
||||
SalesProduct product,
|
||||
IWeightRoundingRepository weightRoundingRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!product.WeightRoundingId.HasValue || product.WeightRoundingId.Value <= 0)
|
||||
{
|
||||
snapshot.WeightRoundingId = null;
|
||||
snapshot.WeightRoundingName = null;
|
||||
snapshot.WeightRoundingInfos = [];
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot.WeightRoundingId = product.WeightRoundingId;
|
||||
var rounding = await weightRoundingRepository.FirstOrDefaultAsync(
|
||||
x => x.Id == product.WeightRoundingId.Value,
|
||||
cancellationToken);
|
||||
snapshot.WeightRoundingName = rounding?.Name;
|
||||
var infos = await weightRoundingRepository.GetAllInfosByRoundingIdAsync(
|
||||
product.WeightRoundingId.Value,
|
||||
cancellationToken);
|
||||
snapshot.WeightRoundingInfos = infos.Select(MapWeightRoundingInfoOutput).ToList();
|
||||
}
|
||||
|
||||
private static KpiAreaInfoOutput MapKpiAreaInfo(KpiAreaInfo entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
AreaId = entity.AreaId,
|
||||
CountryCode = entity.CountryCode,
|
||||
CountryName = entity.CountryName,
|
||||
Province = entity.Province,
|
||||
City = entity.City,
|
||||
LeftPostcode = entity.LeftPostcode,
|
||||
RightPostcode = entity.RightPostcode,
|
||||
CreateTime = entity.CreateTime,
|
||||
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
|
||||
};
|
||||
|
||||
private static WeightRuleInfoOutput MapWeightRuleInfoOutput(WeightRuleInfo entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
RuleId = entity.RuleId,
|
||||
Client = entity.Client,
|
||||
Condition = entity.Condition,
|
||||
BillableWeight = entity.BillableWeight,
|
||||
VolumetricFactor = entity.VolumetricFactor,
|
||||
CreateTime = entity.CreateTime,
|
||||
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
|
||||
};
|
||||
|
||||
private static WeightRoundingInfoOutput MapWeightRoundingInfoOutput(WeightRoundingInfo entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
RoundingId = entity.RoundingId,
|
||||
StartValue = entity.StartValue,
|
||||
EndValue = entity.EndValue,
|
||||
Public = entity.Public,
|
||||
CustomerGroup1 = entity.CustomerGroup1,
|
||||
CustomerGroup2 = entity.CustomerGroup2,
|
||||
CreateTime = entity.CreateTime,
|
||||
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
|
||||
};
|
||||
|
||||
private static WeightRuleInfo MapWeightRuleInfo(WeightRuleInfoOutput output) =>
|
||||
new()
|
||||
{
|
||||
Id = output.Id,
|
||||
RuleId = output.RuleId,
|
||||
Client = output.Client,
|
||||
Condition = output.Condition,
|
||||
BillableWeight = output.BillableWeight,
|
||||
VolumetricFactor = output.VolumetricFactor
|
||||
};
|
||||
|
||||
private static WeightRoundingInfo MapWeightRoundingInfo(WeightRoundingInfoOutput output) =>
|
||||
new()
|
||||
{
|
||||
Id = output.Id,
|
||||
RoundingId = output.RoundingId,
|
||||
StartValue = output.StartValue,
|
||||
EndValue = output.EndValue,
|
||||
Public = output.Public,
|
||||
CustomerGroup1 = output.CustomerGroup1,
|
||||
CustomerGroup2 = output.CustomerGroup2
|
||||
};
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using FluentAssertions;
|
||||
using Oms.Application.Dtos.Channels;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Services.Orders;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
@ -143,9 +144,9 @@ public class ParcelBillingCalculatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateForecastReceivableFees_ShouldApplyCustomerGroupPrice()
|
||||
public void CalculateForecastReceivableFees_UnitFormula_ShouldMultiplyChargeableWeight()
|
||||
{
|
||||
var detail = ParcelBillingTestData.UnitPriceDetail(20M);
|
||||
var detail = ParcelBillingTestData.UnitPriceDetail(15M);
|
||||
detail.CustomerGroupValues = new Dictionary<string, decimal> { ["客户组1"] = 15M };
|
||||
|
||||
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
|
||||
@ -161,7 +162,41 @@ public class ParcelBillingCalculatorTests
|
||||
CustomerGroup.Vip1);
|
||||
|
||||
fees.Should().ContainSingle();
|
||||
fees[0].Amount.Should().Be(15M);
|
||||
fees[0].Amount.Should().Be(30M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateQuoteReceivableFees_TieredContinueWithTotalBase_Weight08_ShouldBe106()
|
||||
{
|
||||
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
|
||||
ParcelBillingTestData.ReceivablePriceItem(
|
||||
"outbound",
|
||||
1,
|
||||
"freight",
|
||||
ParcelBillingTestData.TotalTierDetail("3", 0.5M, 10M, weightInterval: "(0-0.5]"),
|
||||
new ChannelPriceDetailOutput
|
||||
{
|
||||
CalculationFormula = "continue",
|
||||
CalculatedValue = 0,
|
||||
FactorValuesJson = new Dictionary<string, string>
|
||||
{
|
||||
["分区"] = "3",
|
||||
["重量"] = "(0.5-1]"
|
||||
},
|
||||
CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = 2M }
|
||||
}));
|
||||
|
||||
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
|
||||
snapshot,
|
||||
_meta.PriceVersion,
|
||||
_meta.EffectiveTime,
|
||||
0.8M,
|
||||
new[] { "3" },
|
||||
"general",
|
||||
CustomerGroup.Normal);
|
||||
|
||||
fees.Should().ContainSingle();
|
||||
fees[0].Amount.Should().Be(10.6M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -25,10 +25,10 @@ public class PriceCheckSnapshotVerificationTests
|
||||
ChargingTime = "outbound",
|
||||
Details =
|
||||
[
|
||||
Detail(24, 1, "unit", "3", "0,0.5", 11),
|
||||
Detail(25, 1, "unit", "3", "0.5,1", 12),
|
||||
Detail(26, 1, "unit", "3", "1,1.5", 13),
|
||||
Detail(27, 1, "unit", "3", "1.5,2", 14),
|
||||
Detail(24, 1, "total", "3", "0,0.5", 11),
|
||||
Detail(25, 1, "total", "3", "0.5,1", 12),
|
||||
Detail(26, 1, "total", "3", "1,1.5", 13),
|
||||
Detail(27, 1, "total", "3", "1.5,2", 14),
|
||||
]
|
||||
},
|
||||
new SalesProductPriceListOutput
|
||||
@ -97,8 +97,8 @@ public class PriceCheckSnapshotVerificationTests
|
||||
CostItem = "freight",
|
||||
Details =
|
||||
[
|
||||
Detail(34, 1, "unit", "3", "(0-0.5]", 11),
|
||||
Detail(35, 1, "unit", "3", "(0.5-1]", 12),
|
||||
Detail(34, 1, "total", "3", "(0-0.5]", 11),
|
||||
Detail(35, 1, "total", "3", "(0.5-1]", 12),
|
||||
]
|
||||
},
|
||||
new SalesProductPriceListOutput
|
||||
|
||||
131
src/Oms.Tests/RulesSnapshotVerificationTests.cs
Normal file
131
src/Oms.Tests/RulesSnapshotVerificationTests.cs
Normal file
@ -0,0 +1,131 @@
|
||||
using FluentAssertions;
|
||||
using Oms.Application.Dtos.Channels;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Services.Orders;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证规则快照内嵌黑名单/偏远/计重/进位在内存中可匹配。
|
||||
/// </summary>
|
||||
public class RulesSnapshotVerificationTests
|
||||
{
|
||||
private static SalesProductRulesPublishSnapshotDto BuildRulesSnapshot() => new()
|
||||
{
|
||||
BlacklistAreaId = 10,
|
||||
BlacklistAreaInfos =
|
||||
[
|
||||
new KpiAreaInfoOutput
|
||||
{
|
||||
Id = 1,
|
||||
AreaId = 10,
|
||||
CountryCode = "US",
|
||||
LeftPostcode = "90000",
|
||||
RightPostcode = "90010"
|
||||
}
|
||||
],
|
||||
RemoteAreaId = 20,
|
||||
RemoteAreaInfos =
|
||||
[
|
||||
new KpiAreaInfoOutput
|
||||
{
|
||||
Id = 2,
|
||||
AreaId = 20,
|
||||
CountryCode = "US",
|
||||
LeftPostcode = "06000",
|
||||
RightPostcode = "06999"
|
||||
}
|
||||
],
|
||||
WeightRuleId = 1,
|
||||
WeightRuleInfos =
|
||||
[
|
||||
new WeightRuleInfoOutput
|
||||
{
|
||||
Id = 1,
|
||||
RuleId = 1,
|
||||
VolumetricFactor = "6000",
|
||||
Condition = "gw>vw",
|
||||
BillableWeight = "gw"
|
||||
}
|
||||
],
|
||||
WeightRoundingId = 1,
|
||||
WeightRoundingInfos =
|
||||
[
|
||||
new WeightRoundingInfoOutput
|
||||
{
|
||||
Id = 1,
|
||||
RoundingId = 1,
|
||||
StartValue = 0,
|
||||
EndValue = 30,
|
||||
Public = "0.5"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void IsAreaHitFromSnapshot_BlacklistZip90005_ShouldHit()
|
||||
{
|
||||
var snapshot = BuildRulesSnapshot();
|
||||
RulesSnapshotHelper.IsAreaHitFromSnapshot(
|
||||
snapshot.BlacklistAreaInfos,
|
||||
"US",
|
||||
null,
|
||||
null,
|
||||
"90005")
|
||||
.Should()
|
||||
.BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAreaHitFromSnapshot_BlacklistZip06000_ShouldMiss()
|
||||
{
|
||||
var snapshot = BuildRulesSnapshot();
|
||||
RulesSnapshotHelper.IsAreaHitFromSnapshot(
|
||||
snapshot.BlacklistAreaInfos,
|
||||
"US",
|
||||
null,
|
||||
null,
|
||||
"06000")
|
||||
.Should()
|
||||
.BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAreaHitFromSnapshot_RemoteZip06000_ShouldHit()
|
||||
{
|
||||
var snapshot = BuildRulesSnapshot();
|
||||
RulesSnapshotHelper.IsAreaHitFromSnapshot(
|
||||
snapshot.RemoteAreaInfos,
|
||||
"US",
|
||||
null,
|
||||
null,
|
||||
"06000")
|
||||
.Should()
|
||||
.BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotWeightRule_10x10x10_Actual05_ShouldRoundTo05Chargeable()
|
||||
{
|
||||
var snapshot = BuildRulesSnapshot();
|
||||
var forecast = new OrderWeight
|
||||
{
|
||||
Type = OrderWeightTypeCodes.Forecast,
|
||||
Length = 10,
|
||||
Width = 10,
|
||||
Height = 10,
|
||||
ActualWeight = 0.5M
|
||||
};
|
||||
|
||||
var ruleInfos = RulesSnapshotHelper.ToWeightRuleEntities(snapshot.WeightRuleInfos);
|
||||
var (_, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos);
|
||||
var ranges = RulesSnapshotHelper.ToWeightRoundingEntities(snapshot.WeightRoundingInfos);
|
||||
var chargeable = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
|
||||
|
||||
rawWeight.Should().Be(0.5M);
|
||||
chargeable.Should().Be(0.5M);
|
||||
}
|
||||
}
|
||||
@ -95,7 +95,8 @@ internal static class ParcelBillingTestData
|
||||
public static ChannelPriceDetailOutput TotalTierDetail(
|
||||
string zone,
|
||||
decimal weightTier,
|
||||
decimal normalCustomerPrice) =>
|
||||
decimal normalCustomerPrice,
|
||||
string? weightInterval = null) =>
|
||||
new()
|
||||
{
|
||||
CalculationFormula = "total",
|
||||
@ -103,7 +104,7 @@ internal static class ParcelBillingTestData
|
||||
FactorValuesJson = new Dictionary<string, string>
|
||||
{
|
||||
["分区"] = zone,
|
||||
["重量"] = weightTier.ToString(CultureInfo.InvariantCulture)
|
||||
["重量"] = weightInterval ?? weightTier.ToString(CultureInfo.InvariantCulture)
|
||||
},
|
||||
CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = normalCustomerPrice }
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user