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>
|
/// </summary>
|
||||||
public List<SalesProductAllocationProviderOutput> Providers { get; set; } = [];
|
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>
|
/// <summary>
|
||||||
|
|||||||
@ -174,7 +174,7 @@ public partial class SalesProductService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
snapshot = new SalesProductRulesPublishSnapshot
|
var rulesSnapshot = new SalesProductRulesPublishSnapshot
|
||||||
{
|
{
|
||||||
Product = new SalesProductSaveInput
|
Product = new SalesProductSaveInput
|
||||||
{
|
{
|
||||||
@ -197,6 +197,13 @@ public partial class SalesProductService
|
|||||||
},
|
},
|
||||||
Providers = (await GetAllocationCoreAsync(salesProductId)).Providers
|
Providers = (await GetAllocationCoreAsync(salesProductId)).Providers
|
||||||
};
|
};
|
||||||
|
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
|
||||||
|
rulesSnapshot,
|
||||||
|
product,
|
||||||
|
KpiAreaRepository,
|
||||||
|
WeightRuleRepository,
|
||||||
|
WeightRoundingRepository);
|
||||||
|
snapshot = rulesSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。
|
// 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 ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
|
||||||
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
|
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
|
||||||
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
||||||
|
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
|
||||||
|
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询销售产品列表。
|
/// 查询销售产品列表。
|
||||||
|
|||||||
@ -57,6 +57,7 @@ public partial class OrderService
|
|||||||
|
|
||||||
if (await IsBlacklistHitAsync(
|
if (await IsBlacklistHitAsync(
|
||||||
product,
|
product,
|
||||||
|
snapshotBinding.Rules,
|
||||||
country.CountryCode,
|
country.CountryCode,
|
||||||
input.Receiver?.Province,
|
input.Receiver?.Province,
|
||||||
input.Receiver?.City,
|
input.Receiver?.City,
|
||||||
@ -76,13 +77,14 @@ public partial class OrderService
|
|||||||
{
|
{
|
||||||
order.IsRemote = await IsRemoteHitAsync(
|
order.IsRemote = await IsRemoteHitAsync(
|
||||||
product,
|
product,
|
||||||
|
snapshotBinding.Rules,
|
||||||
country.CountryCode,
|
country.CountryCode,
|
||||||
input.Receiver?.Province,
|
input.Receiver?.Province,
|
||||||
input.Receiver?.City,
|
input.Receiver?.City,
|
||||||
input.Receiver?.ZipCode) ? (byte)1 : (byte)0;
|
input.Receiver?.ZipCode) ? (byte)1 : (byte)0;
|
||||||
|
|
||||||
var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast);
|
var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast);
|
||||||
await ApplyChargeableWeightAsync(product, forecast);
|
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
|
||||||
SyncWeightRows(weights, forecast);
|
SyncWeightRows(weights, forecast);
|
||||||
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
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
|
Product = new SalesProductSaveInput
|
||||||
{
|
{
|
||||||
@ -304,7 +306,14 @@ public partial class OrderService
|
|||||||
IsActive = product.IsActive,
|
IsActive = product.IsActive,
|
||||||
Scheme = product.Scheme
|
Scheme = product.Scheme
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
|
||||||
|
snapshot,
|
||||||
|
product,
|
||||||
|
KpiAreaRepository,
|
||||||
|
WeightRuleRepository,
|
||||||
|
WeightRoundingRepository);
|
||||||
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SalesProductPricePublishSnapshotDto> BuildLivePriceSnapshotAsync(SalesProduct product)
|
private async Task<SalesProductPricePublishSnapshotDto> BuildLivePriceSnapshotAsync(SalesProduct product)
|
||||||
@ -372,17 +381,29 @@ public partial class OrderService
|
|||||||
|
|
||||||
private async Task<bool> IsBlacklistHitAsync(
|
private async Task<bool> IsBlacklistHitAsync(
|
||||||
SalesProduct product,
|
SalesProduct product,
|
||||||
|
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||||
string countryCode,
|
string countryCode,
|
||||||
string? province,
|
string? province,
|
||||||
string? city,
|
string? city,
|
||||||
string? zipCode)
|
string? zipCode)
|
||||||
{
|
{
|
||||||
if (!product.BlacklistAreaId.HasValue || product.BlacklistAreaId <= 0)
|
var areaId = ResolveEffectiveBlacklistAreaId(product, rulesSnapshot);
|
||||||
|
if (!areaId.HasValue)
|
||||||
{
|
{
|
||||||
return false;
|
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(
|
return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
|
||||||
rule,
|
rule,
|
||||||
countryCode,
|
countryCode,
|
||||||
@ -393,17 +414,29 @@ public partial class OrderService
|
|||||||
|
|
||||||
private async Task<bool> IsRemoteHitAsync(
|
private async Task<bool> IsRemoteHitAsync(
|
||||||
SalesProduct product,
|
SalesProduct product,
|
||||||
|
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||||
string countryCode,
|
string countryCode,
|
||||||
string? province,
|
string? province,
|
||||||
string? city,
|
string? city,
|
||||||
string? zipCode)
|
string? zipCode)
|
||||||
{
|
{
|
||||||
if (!product.RemoteAreaId.HasValue || product.RemoteAreaId <= 0)
|
var areaId = ResolveEffectiveRemoteAreaId(product, rulesSnapshot);
|
||||||
|
if (!areaId.HasValue)
|
||||||
{
|
{
|
||||||
return false;
|
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(
|
return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
|
||||||
rule,
|
rule,
|
||||||
countryCode,
|
countryCode,
|
||||||
@ -412,6 +445,50 @@ public partial class OrderService
|
|||||||
zipCode));
|
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(
|
private static int? ResolveEffectivePriceAreaId(
|
||||||
SalesProduct product,
|
SalesProduct product,
|
||||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||||
@ -482,37 +559,60 @@ public partial class OrderService
|
|||||||
return keys.Count > 0 ? keys : null;
|
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
|
try
|
||||||
{
|
{
|
||||||
if (!product.WeightRuleId.HasValue || product.WeightRuleId <= 0)
|
var weightRuleId = ResolveEffectiveWeightRuleId(product, rulesSnapshot);
|
||||||
|
if (!weightRuleId.HasValue)
|
||||||
{
|
{
|
||||||
throw new BusinessException("未配置计重规则");
|
throw new BusinessException("未配置计重规则");
|
||||||
}
|
}
|
||||||
|
|
||||||
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRuleId.Value);
|
IReadOnlyList<WeightRuleInfo> ruleInfos;
|
||||||
|
if (rulesSnapshot?.WeightRuleInfos is { Count: > 0 })
|
||||||
|
{
|
||||||
|
ruleInfos = RulesSnapshotHelper.ToWeightRuleEntities(rulesSnapshot.WeightRuleInfos);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value);
|
||||||
if (weightRule == null)
|
if (weightRule == null)
|
||||||
{
|
{
|
||||||
throw new BusinessException("未配置计重规则");
|
throw new BusinessException("未配置计重规则");
|
||||||
}
|
}
|
||||||
|
|
||||||
var ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value);
|
ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos);
|
var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos);
|
||||||
forecast.VolumetricWeight = volumetricWeight;
|
forecast.VolumetricWeight = volumetricWeight;
|
||||||
|
|
||||||
if (!product.WeightRoundingId.HasValue || product.WeightRoundingId <= 0)
|
var weightRoundingId = ResolveEffectiveWeightRoundingId(product, rulesSnapshot);
|
||||||
|
if (!weightRoundingId.HasValue)
|
||||||
{
|
{
|
||||||
throw new BusinessException("未配置计重进位");
|
throw new BusinessException("未配置计重进位");
|
||||||
}
|
}
|
||||||
|
|
||||||
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRoundingId.Value);
|
IReadOnlyList<WeightRoundingInfo> ranges;
|
||||||
|
if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 })
|
||||||
|
{
|
||||||
|
ranges = RulesSnapshotHelper.ToWeightRoundingEntities(rulesSnapshot.WeightRoundingInfos);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId.Value);
|
||||||
if (rounding == null)
|
if (rounding == null)
|
||||||
{
|
{
|
||||||
throw new BusinessException("未配置计重进位");
|
throw new BusinessException("未配置计重进位");
|
||||||
}
|
}
|
||||||
|
|
||||||
var ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value);
|
ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
|
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
|
||||||
}
|
}
|
||||||
catch (BusinessException)
|
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)
|
private static List<OrderWeight> BuildInitialWeightRows(int orderId, OrderParcelCreateInput input)
|
||||||
{
|
{
|
||||||
var cubic = ParcelBillingCalculator.CalculateCubic(input.Length!.Value, input.Width!.Value, input.Height!.Value);
|
var cubic = ParcelBillingCalculator.CalculateCubic(input.Length!.Value, input.Width!.Value, input.Height!.Value);
|
||||||
|
|||||||
@ -196,6 +196,7 @@ public partial class OrderService
|
|||||||
|
|
||||||
if (await IsBlacklistHitAsync(
|
if (await IsBlacklistHitAsync(
|
||||||
product,
|
product,
|
||||||
|
snapshotBinding.Rules,
|
||||||
context.CountryCode,
|
context.CountryCode,
|
||||||
context.Province,
|
context.Province,
|
||||||
context.City,
|
context.City,
|
||||||
@ -210,7 +211,7 @@ public partial class OrderService
|
|||||||
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ApplyChargeableWeightAsync(product, forecast);
|
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
|
||||||
}
|
}
|
||||||
catch (BusinessException ex)
|
catch (BusinessException ex)
|
||||||
{
|
{
|
||||||
@ -281,6 +282,7 @@ public partial class OrderService
|
|||||||
|
|
||||||
var isRemote = await IsRemoteHitAsync(
|
var isRemote = await IsRemoteHitAsync(
|
||||||
product,
|
product,
|
||||||
|
snapshotBinding.Rules,
|
||||||
context.CountryCode,
|
context.CountryCode,
|
||||||
context.Province,
|
context.Province,
|
||||||
context.City,
|
context.City,
|
||||||
|
|||||||
@ -631,7 +631,13 @@ internal static class ParcelBillingCalculator
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var amount = CalculateDetailAmount(detail, chargeableWeight, customerGroup);
|
var amount = CalculateDetailAmount(
|
||||||
|
priceItem.Details,
|
||||||
|
detail,
|
||||||
|
zoneKeys,
|
||||||
|
cargoAttribute,
|
||||||
|
chargeableWeight,
|
||||||
|
customerGroup);
|
||||||
fees.Add(new OrderFee
|
fees.Add(new OrderFee
|
||||||
{
|
{
|
||||||
Type = feeType,
|
Type = feeType,
|
||||||
@ -835,16 +841,119 @@ internal static class ParcelBillingCalculator
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static decimal CalculateDetailAmount(
|
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,
|
ChannelPriceDetailOutput detail,
|
||||||
decimal chargeableWeight,
|
decimal chargeableWeight,
|
||||||
CustomerGroup? customerGroup)
|
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)
|
||||||
{
|
{
|
||||||
"unit" or "total" => ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0,
|
var factors = ParseFactorDictionary(detail.FactorValuesJson);
|
||||||
"continue" => CalculateContinuePrice(detail, chargeableWeight, customerGroup),
|
if (TryGetWeightFactorText(factors, out var weightText)
|
||||||
_ => detail.CalculatedValue ?? 0
|
&& TryParseWeightInterval(weightText, out var rangeStart, out _)
|
||||||
};
|
&& TryFindAdjacentTotalBasePrice(
|
||||||
|
allDetails,
|
||||||
|
zoneKeys,
|
||||||
|
cargoLabel,
|
||||||
|
rangeStart,
|
||||||
|
customerGroup,
|
||||||
|
out var basePrice))
|
||||||
|
{
|
||||||
|
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>
|
/// <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 FluentAssertions;
|
||||||
|
using Oms.Application.Dtos.Channels;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Services.Orders;
|
using Oms.Application.Services.Orders;
|
||||||
using Oms.Domain.CustomersAggregate.Enums;
|
using Oms.Domain.CustomersAggregate.Enums;
|
||||||
@ -143,9 +144,9 @@ public class ParcelBillingCalculatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 };
|
detail.CustomerGroupValues = new Dictionary<string, decimal> { ["客户组1"] = 15M };
|
||||||
|
|
||||||
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
|
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
|
||||||
@ -161,7 +162,41 @@ public class ParcelBillingCalculatorTests
|
|||||||
CustomerGroup.Vip1);
|
CustomerGroup.Vip1);
|
||||||
|
|
||||||
fees.Should().ContainSingle();
|
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]
|
[Fact]
|
||||||
|
|||||||
@ -25,10 +25,10 @@ public class PriceCheckSnapshotVerificationTests
|
|||||||
ChargingTime = "outbound",
|
ChargingTime = "outbound",
|
||||||
Details =
|
Details =
|
||||||
[
|
[
|
||||||
Detail(24, 1, "unit", "3", "0,0.5", 11),
|
Detail(24, 1, "total", "3", "0,0.5", 11),
|
||||||
Detail(25, 1, "unit", "3", "0.5,1", 12),
|
Detail(25, 1, "total", "3", "0.5,1", 12),
|
||||||
Detail(26, 1, "unit", "3", "1,1.5", 13),
|
Detail(26, 1, "total", "3", "1,1.5", 13),
|
||||||
Detail(27, 1, "unit", "3", "1.5,2", 14),
|
Detail(27, 1, "total", "3", "1.5,2", 14),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
new SalesProductPriceListOutput
|
new SalesProductPriceListOutput
|
||||||
@ -97,8 +97,8 @@ public class PriceCheckSnapshotVerificationTests
|
|||||||
CostItem = "freight",
|
CostItem = "freight",
|
||||||
Details =
|
Details =
|
||||||
[
|
[
|
||||||
Detail(34, 1, "unit", "3", "(0-0.5]", 11),
|
Detail(34, 1, "total", "3", "(0-0.5]", 11),
|
||||||
Detail(35, 1, "unit", "3", "(0.5-1]", 12),
|
Detail(35, 1, "total", "3", "(0.5-1]", 12),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
new SalesProductPriceListOutput
|
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(
|
public static ChannelPriceDetailOutput TotalTierDetail(
|
||||||
string zone,
|
string zone,
|
||||||
decimal weightTier,
|
decimal weightTier,
|
||||||
decimal normalCustomerPrice) =>
|
decimal normalCustomerPrice,
|
||||||
|
string? weightInterval = null) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
CalculationFormula = "total",
|
CalculationFormula = "total",
|
||||||
@ -103,7 +104,7 @@ internal static class ParcelBillingTestData
|
|||||||
FactorValuesJson = new Dictionary<string, string>
|
FactorValuesJson = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["分区"] = zone,
|
["分区"] = zone,
|
||||||
["重量"] = weightTier.ToString(CultureInfo.InvariantCulture)
|
["重量"] = weightInterval ?? weightTier.ToString(CultureInfo.InvariantCulture)
|
||||||
},
|
},
|
||||||
CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = normalCustomerPrice }
|
CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = normalCustomerPrice }
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user