feat: 规则快照固化计重/分区规则并修正卖价公式

规则发布写入黑名单、偏远、计重规则与进位明细;查价/下单优先读快照。修正 unit/total/continue 算价及续单价相邻档位总价基数。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-26 12:57:34 +08:00
parent 8aa6ceb677
commit 2c69e8e5b8
11 changed files with 750 additions and 42 deletions

View File

@ -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>

View File

@ -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; } = [];
}
}

View File

@ -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>
/// 查询销售产品列表。

View File

@ -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);
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)
{
throw new BusinessException("未配置计重规则");
}
var ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value);
ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(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);
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)
{
throw new BusinessException("未配置计重进位");
}
var ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value);
ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(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);

View File

@ -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,

View File

@ -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)
{
"unit" or "total" => ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0,
"continue" => CalculateContinuePrice(detail, chargeableWeight, customerGroup),
_ => detail.CalculatedValue ?? 0
};
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))
{
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>

View 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
};
}

View File

@ -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]

View File

@ -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

View 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);
}
}

View File

@ -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 }
};