查价增强与小包国家码:查价忽略收费时机,支持 unit/total 字典公式与重量左开右闭区间,未命中返回 failures 步骤与原因;收发件人 countryCode 改为 ISO 二字码。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
13ccd198d5
commit
148caae1e7
@ -31,9 +31,9 @@ public class OrderParcelReceiverInput
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 国家/地区代码。
|
||||
/// 国家/地区二字码(ISO,如 CN、US);兼容传 t_country.Id 数字字符串。
|
||||
/// </summary>
|
||||
public int? CountryCode { get; set; }
|
||||
public string? CountryCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 省份。
|
||||
|
||||
@ -31,9 +31,9 @@ public class OrderParcelSenderInput
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 国家/地区代码。
|
||||
/// 国家/地区二字码(ISO,如 CN、US);兼容传 t_country.Id 数字字符串。
|
||||
/// </summary>
|
||||
public int? CountryCode { get; set; }
|
||||
public string? CountryCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 省份。
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 查价未命中原因(按产品维度)。
|
||||
/// </summary>
|
||||
public class OrderPriceCheckFailureOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 销售产品主键。
|
||||
/// </summary>
|
||||
public int ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 产品代码。
|
||||
/// </summary>
|
||||
public string ProductCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 产品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 未命中步骤:product / snapshot / blacklist / weight / zone / price。
|
||||
/// </summary>
|
||||
public string Step { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 未命中原因说明。
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 查价接口返回体。
|
||||
/// </summary>
|
||||
public class OrderPriceCheckResultOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 试算成功的产品列表。
|
||||
/// </summary>
|
||||
public List<OrderPriceCheckOutput> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 试算失败或未命中的产品及原因。
|
||||
/// </summary>
|
||||
public List<OrderPriceCheckFailureOutput> Failures { get; set; } = [];
|
||||
}
|
||||
@ -10,6 +10,7 @@ using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Domain.Shared.Enums;
|
||||
using Oms.Domain.SystemAggregate.Entities;
|
||||
using Oms.Domain.SystemAggregate.Repositories;
|
||||
|
||||
namespace Oms.Application.Services.Orders;
|
||||
@ -52,11 +53,7 @@ public partial class OrderService
|
||||
}
|
||||
|
||||
var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
|
||||
var country = await CountryRepository.FirstOrDefaultAsync(x => x.Id == input.Receiver!.CountryCode);
|
||||
if (country == null)
|
||||
{
|
||||
throw new BusinessException("收件国家不存在");
|
||||
}
|
||||
var country = await GetCountryOrThrowAsync(input.Receiver!.CountryCode);
|
||||
|
||||
if (await IsBlacklistHitAsync(
|
||||
product,
|
||||
@ -73,7 +70,7 @@ public partial class OrderService
|
||||
order = await OrderRepository.InsertAsync(order);
|
||||
|
||||
var weights = BuildInitialWeightRows(order.Id, input);
|
||||
await SaveParcelChildrenAsync(order, input, weights);
|
||||
await SaveParcelChildrenAsync(order, input, weights, country);
|
||||
|
||||
try
|
||||
{
|
||||
@ -195,7 +192,7 @@ public partial class OrderService
|
||||
|
||||
if (input.Receiver == null
|
||||
|| string.IsNullOrWhiteSpace(input.Receiver.RecipientsName)
|
||||
|| !input.Receiver.CountryCode.HasValue
|
||||
|| string.IsNullOrWhiteSpace(input.Receiver.CountryCode)
|
||||
|| string.IsNullOrWhiteSpace(input.Receiver.ZipCode)
|
||||
|| string.IsNullOrWhiteSpace(input.Receiver.StreetAddress1))
|
||||
{
|
||||
@ -571,16 +568,26 @@ public partial class OrderService
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SaveParcelChildrenAsync(Order order, OrderParcelCreateInput input, List<OrderWeight> weights)
|
||||
private async Task SaveParcelChildrenAsync(
|
||||
Order order,
|
||||
OrderParcelCreateInput input,
|
||||
List<OrderWeight> weights,
|
||||
Country receiverCountry)
|
||||
{
|
||||
if (input.Sender != null)
|
||||
{
|
||||
int? senderCountryId = null;
|
||||
if (!string.IsNullOrWhiteSpace(input.Sender.CountryCode))
|
||||
{
|
||||
senderCountryId = (await GetCountryOrThrowAsync(input.Sender.CountryCode, "发件国家不存在")).Id;
|
||||
}
|
||||
|
||||
var sender = await OrderRepository.InsertSenderAsync(new OrderSender
|
||||
{
|
||||
OrderId = order.Id,
|
||||
SenderName = input.Sender.SenderName,
|
||||
Company = input.Sender.Company,
|
||||
Country = input.Sender.CountryCode,
|
||||
Country = senderCountryId,
|
||||
Province = input.Sender.Province,
|
||||
City = input.Sender.City,
|
||||
ZipCode = input.Sender.ZipCode,
|
||||
@ -603,7 +610,7 @@ public partial class OrderService
|
||||
OrderId = order.Id,
|
||||
RecipientsName = input.Receiver.RecipientsName,
|
||||
CompanyName = input.Receiver.CompanyName,
|
||||
Country = input.Receiver.CountryCode,
|
||||
Country = receiverCountry.Id,
|
||||
Phone = input.Receiver.Phone,
|
||||
TelePhone = input.Receiver.TelePhone,
|
||||
ZipCode = input.Receiver.ZipCode,
|
||||
|
||||
@ -2,14 +2,14 @@ using Fake.Application;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Domain.ChannelsAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.Shared.Enums;
|
||||
using Oms.Domain.SystemAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
public partial class OrderService
|
||||
{
|
||||
private async Task<List<OrderPriceCheckOutput>> BuildPriceCheckOutputsAsync(OrderPriceCheckInput input)
|
||||
private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input)
|
||||
{
|
||||
ValidatePriceCheckInput(input);
|
||||
var bindTime = DateTime.Now;
|
||||
@ -35,41 +35,58 @@ public partial class OrderService
|
||||
|| x.ProductName.Contains(shippingKeyword)
|
||||
|| x.ProductCode.Contains(shippingKeyword)));
|
||||
|
||||
var result = new OrderPriceCheckResultOutput();
|
||||
if (products.Count == 0)
|
||||
{
|
||||
result.Failures.Add(new OrderPriceCheckFailureOutput
|
||||
{
|
||||
Step = "product",
|
||||
Reason = string.IsNullOrWhiteSpace(shippingKeyword)
|
||||
? "未找到已启用的销售产品"
|
||||
: $"未找到运输方式/产品代码匹配「{shippingKeyword}」的已启用产品"
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
var warehouses = await WarehouseAddressRepository.GetListAsync();
|
||||
var defaultWarehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault();
|
||||
var outputs = new List<OrderPriceCheckOutput>();
|
||||
|
||||
foreach (var product in products.OrderBy(x => x.Id))
|
||||
{
|
||||
var quote = await TryComputeProductQuoteAsync(product, quoteContext, customerGroup, bindTime);
|
||||
if (quote == null)
|
||||
var attempt = await TryComputePriceCheckQuoteAsync(product, quoteContext, customerGroup, bindTime);
|
||||
if (attempt.Success)
|
||||
{
|
||||
var quote = attempt.Quote!;
|
||||
var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees);
|
||||
result.Items.Add(new OrderPriceCheckOutput
|
||||
{
|
||||
Id = quote.ProductId,
|
||||
ProductCode = quote.ProductCode,
|
||||
ShippingMethod = quote.ProductName,
|
||||
Warehouse = defaultWarehouse?.WarehouseName,
|
||||
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
|
||||
VolumetricWeight = quote.VolumetricWeight,
|
||||
ChargeableWeight = quote.ChargeableWeight,
|
||||
CostFormula = costFormula,
|
||||
Freight = freight,
|
||||
OtherFees = otherFees,
|
||||
TotalCost = totalCost,
|
||||
MaxDeliveryDays = product.MaxTimeliness,
|
||||
TotalCostNum = totalCost,
|
||||
RulesVersion = quote.RulesVersion,
|
||||
PriceVersion = quote.PriceVersion,
|
||||
IsRemote = quote.IsRemote
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees);
|
||||
outputs.Add(new OrderPriceCheckOutput
|
||||
if (attempt.Failure != null)
|
||||
{
|
||||
Id = quote.ProductId,
|
||||
ProductCode = quote.ProductCode,
|
||||
ShippingMethod = quote.ProductName,
|
||||
Warehouse = defaultWarehouse?.WarehouseName,
|
||||
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
|
||||
VolumetricWeight = quote.VolumetricWeight,
|
||||
ChargeableWeight = quote.ChargeableWeight,
|
||||
CostFormula = costFormula,
|
||||
Freight = freight,
|
||||
OtherFees = otherFees,
|
||||
TotalCost = totalCost,
|
||||
MaxDeliveryDays = product.MaxTimeliness,
|
||||
TotalCostNum = totalCost,
|
||||
RulesVersion = quote.RulesVersion,
|
||||
PriceVersion = quote.PriceVersion,
|
||||
IsRemote = quote.IsRemote
|
||||
});
|
||||
result.Failures.Add(attempt.Failure);
|
||||
}
|
||||
}
|
||||
|
||||
return outputs;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ValidatePriceCheckInput(OrderPriceCheckInput input)
|
||||
@ -93,10 +110,45 @@ public partial class OrderService
|
||||
|
||||
private async Task<string> ResolveDestinationCountryCodeAsync(string destinationCountry)
|
||||
{
|
||||
var trimmed = destinationCountry.Trim();
|
||||
var country = await GetCountryOrThrowAsync(destinationCountry, "目的国不存在");
|
||||
return country.CountryCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将国家入参解析为 t_country 记录(支持 ISO 二字码、中文/英文名;兼容数字 Id)。
|
||||
/// </summary>
|
||||
private async Task<Country> GetCountryOrThrowAsync(string? countryInput, string notFoundMessage = "收件国家不存在")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(countryInput))
|
||||
{
|
||||
throw new BusinessException(notFoundMessage);
|
||||
}
|
||||
|
||||
var trimmed = countryInput.Trim();
|
||||
if (int.TryParse(trimmed, out var countryId) && countryId > 0)
|
||||
{
|
||||
var byId = await CountryRepository.FirstOrDefaultAsync(x => x.Id == countryId);
|
||||
if (byId != null)
|
||||
{
|
||||
return byId;
|
||||
}
|
||||
}
|
||||
|
||||
var upper = trimmed.ToUpperInvariant();
|
||||
var country = await CountryRepository.FirstOrDefaultAsync(x =>
|
||||
x.CountryCode == trimmed || x.ChineseName == trimmed || x.EnglishName == trimmed);
|
||||
return country?.CountryCode ?? trimmed.ToUpperInvariant();
|
||||
x.CountryCode == upper || x.CountryCode == trimmed);
|
||||
if (country == null)
|
||||
{
|
||||
country = await CountryRepository.FirstOrDefaultAsync(x =>
|
||||
x.ChineseName == trimmed || x.EnglishName == trimmed);
|
||||
}
|
||||
|
||||
if (country == null)
|
||||
{
|
||||
throw new BusinessException(notFoundMessage);
|
||||
}
|
||||
|
||||
return country;
|
||||
}
|
||||
|
||||
private async Task<CustomerGroup?> ResolveCurrentCustomerGroupAsync()
|
||||
@ -119,68 +171,101 @@ public partial class OrderService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对单个销售产品执行预报试算(与 parcel/create 同源,不落库)。
|
||||
/// 查价试算:按国家+邮编匹配分区,在卖价快照中匹配明细(忽略收费时机)。
|
||||
/// </summary>
|
||||
private async Task<ParcelQuoteResult?> TryComputeProductQuoteAsync(
|
||||
private async Task<PriceCheckAttemptResult> TryComputePriceCheckQuoteAsync(
|
||||
SalesProduct product,
|
||||
ParcelQuoteContext context,
|
||||
CustomerGroup? customerGroup,
|
||||
DateTime bindTime)
|
||||
{
|
||||
if (product.IsActive != EnableStatus.Enabled)
|
||||
{
|
||||
return PriceCheckFailure(product, "product", "产品未启用");
|
||||
}
|
||||
|
||||
ProductSnapshotBinding snapshotBinding;
|
||||
try
|
||||
{
|
||||
if (product.IsActive != EnableStatus.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
return PriceCheckFailure(product, "snapshot", ex.Message);
|
||||
}
|
||||
|
||||
var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
|
||||
if (await IsBlacklistHitAsync(
|
||||
product,
|
||||
context.CountryCode,
|
||||
context.Province,
|
||||
context.City,
|
||||
context.PostalCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (await IsBlacklistHitAsync(
|
||||
product,
|
||||
context.CountryCode,
|
||||
context.Province,
|
||||
context.City,
|
||||
context.PostalCode))
|
||||
{
|
||||
return PriceCheckFailure(
|
||||
product,
|
||||
"blacklist",
|
||||
$"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 命中产品黑名单");
|
||||
}
|
||||
|
||||
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
||||
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
||||
try
|
||||
{
|
||||
await ApplyChargeableWeightAsync(product, forecast);
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
return PriceCheckFailure(product, "weight", ex.Message);
|
||||
}
|
||||
|
||||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
||||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
||||
product,
|
||||
context.CountryCode,
|
||||
context.Province,
|
||||
context.City,
|
||||
context.PostalCode);
|
||||
if (zoneKeys == null || zoneKeys.Count == 0)
|
||||
{
|
||||
return PriceCheckFailure(
|
||||
product,
|
||||
context.CountryCode,
|
||||
context.Province,
|
||||
context.City,
|
||||
context.PostalCode);
|
||||
if (zoneKeys == null || zoneKeys.Count == 0)
|
||||
"zone",
|
||||
$"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未匹配产品价格分区(请检查 PriceAreaId 及分区规则)");
|
||||
}
|
||||
|
||||
var chargeableWeight = forecast.ChargeableWeight ?? 0;
|
||||
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
|
||||
snapshotBinding.Price,
|
||||
snapshotBinding.PriceVersion,
|
||||
snapshotBinding.PriceEffectiveTime,
|
||||
chargeableWeight,
|
||||
zoneKeys,
|
||||
context.CargoAttribute,
|
||||
customerGroup);
|
||||
|
||||
if (fees.Count == 0)
|
||||
{
|
||||
var enabledItems = snapshotBinding.Price.Prices.Count(x => x.Status == 1);
|
||||
if (enabledItems == 0)
|
||||
{
|
||||
return null;
|
||||
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项");
|
||||
}
|
||||
|
||||
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
|
||||
snapshotBinding.Price,
|
||||
snapshotBinding.PriceVersion,
|
||||
snapshotBinding.PriceEffectiveTime,
|
||||
forecast.ChargeableWeight ?? 0,
|
||||
zoneKeys,
|
||||
context.CargoAttribute,
|
||||
customerGroup);
|
||||
|
||||
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var isRemote = await IsRemoteHitAsync(
|
||||
return PriceCheckFailure(
|
||||
product,
|
||||
context.CountryCode,
|
||||
context.Province,
|
||||
context.City,
|
||||
context.PostalCode);
|
||||
"price",
|
||||
$"计费重 {chargeableWeight}kg,匹配分区键 [{string.Join(", ", zoneKeys)}],货物属性 {context.CargoAttribute}," +
|
||||
$"在卖价版本 {snapshotBinding.PriceVersion} 的 {enabledItems} 个启用价格项中未命中任何明细(请检查分区/重量区间/货物类型)");
|
||||
}
|
||||
|
||||
return new ParcelQuoteResult
|
||||
var isRemote = await IsRemoteHitAsync(
|
||||
product,
|
||||
context.CountryCode,
|
||||
context.Province,
|
||||
context.City,
|
||||
context.PostalCode);
|
||||
|
||||
return new PriceCheckAttemptResult
|
||||
{
|
||||
Quote = new ParcelQuoteResult
|
||||
{
|
||||
ProductId = product.Id,
|
||||
ProductCode = product.ProductCode,
|
||||
@ -188,19 +273,27 @@ public partial class OrderService
|
||||
RulesVersion = snapshotBinding.RulesVersion,
|
||||
PriceVersion = snapshotBinding.PriceVersion,
|
||||
VolumetricWeight = forecast.VolumetricWeight ?? 0,
|
||||
ChargeableWeight = forecast.ChargeableWeight ?? 0,
|
||||
ChargeableWeight = chargeableWeight,
|
||||
ZoneName = zoneKeys[0],
|
||||
IsRemote = isRemote,
|
||||
Fees = fees
|
||||
};
|
||||
}
|
||||
catch (BusinessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static PriceCheckAttemptResult PriceCheckFailure(SalesProduct product, string step, string reason) =>
|
||||
new()
|
||||
{
|
||||
Failure = new OrderPriceCheckFailureOutput
|
||||
{
|
||||
ProductId = product.Id,
|
||||
ProductCode = product.ProductCode,
|
||||
ProductName = product.ProductName,
|
||||
Step = step,
|
||||
Reason = reason
|
||||
}
|
||||
};
|
||||
|
||||
private static string FormatPostalCode(string? postalCode) =>
|
||||
string.IsNullOrWhiteSpace(postalCode) ? "-" : postalCode.Trim();
|
||||
}
|
||||
|
||||
@ -232,15 +232,14 @@ public partial class OrderService : ApplicationService
|
||||
/// <returns>订单运费试算列表结果。</returns>
|
||||
/// <remarks>
|
||||
/// 核心业务逻辑:
|
||||
/// 1. 根据试算参数构造报价请求。
|
||||
/// 2. 汇总费用、时效等试算结果。
|
||||
/// 3. 按接口约定返回结果列表。
|
||||
/// 1. 按 bindTime 解析销售产品最新生效卖价/规则快照(与 parcel/create 同源)。
|
||||
/// 2. 计重、分区匹配后基于卖价快照计算预报阶段应收费用。
|
||||
/// 3. 返回试算金额及 rulesVersion、priceVersion。
|
||||
/// </remarks>
|
||||
[HttpPost("/order/price-check")]
|
||||
public async Task<OmsItemsResult<OrderPriceCheckOutput>> PriceCheckAsync(OrderPriceCheckInput input)
|
||||
public async Task<OrderPriceCheckResultOutput> PriceCheckAsync(OrderPriceCheckInput input)
|
||||
{
|
||||
var items = await BuildPriceCheckOutputsAsync(input);
|
||||
return new OmsItemsResult<OrderPriceCheckOutput> { Items = items };
|
||||
return await BuildPriceCheckResultAsync(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -434,9 +434,8 @@ internal static class ParcelBillingCalculator
|
||||
decimal chargeableWeight)
|
||||
{
|
||||
var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
|
||||
var tierCandidates = new List<(ChannelPriceDetailOutput Detail, decimal Tier)>();
|
||||
ChannelPriceDetailOutput? rangeHit = null;
|
||||
ChannelPriceDetailOutput? genericHit = null;
|
||||
ChannelPriceDetailOutput? bestMatch = null;
|
||||
var bestIntervalWidth = decimal.MaxValue;
|
||||
|
||||
foreach (var detail in details)
|
||||
{
|
||||
@ -446,113 +445,132 @@ internal static class ParcelBillingCalculator
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!MatchFactorValue(factors, "货物类型", cargoLabel)
|
||||
&& factors.ContainsKey("货物类型"))
|
||||
if (!MatchCargoTypeFactor(factors, cargoLabel))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseWeightRange(factors, out var rangeStart, out var rangeEnd))
|
||||
if (!TryGetWeightFactorText(factors, out var weightText))
|
||||
{
|
||||
if (chargeableWeight >= rangeStart && chargeableWeight <= rangeEnd)
|
||||
if (bestMatch == null)
|
||||
{
|
||||
rangeHit = detail;
|
||||
bestMatch = detail;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseSingleWeightTier(factors, out var tier))
|
||||
if (!TryParseWeightInterval(weightText, out var rangeStart, out var rangeEnd))
|
||||
{
|
||||
tierCandidates.Add((detail, tier));
|
||||
continue;
|
||||
}
|
||||
|
||||
genericHit = detail;
|
||||
}
|
||||
|
||||
if (rangeHit != null)
|
||||
{
|
||||
return rangeHit;
|
||||
}
|
||||
|
||||
if (tierCandidates.Count > 0)
|
||||
{
|
||||
var ceiling = tierCandidates
|
||||
.Where(x => x.Tier >= chargeableWeight)
|
||||
.OrderBy(x => x.Tier)
|
||||
.Select(x => x.Detail)
|
||||
.FirstOrDefault();
|
||||
if (ceiling != null)
|
||||
if (!WeightInLeftOpenRightClosedInterval(chargeableWeight, rangeStart, rangeEnd))
|
||||
{
|
||||
return ceiling;
|
||||
continue;
|
||||
}
|
||||
|
||||
return tierCandidates.OrderByDescending(x => x.Tier).First().Detail;
|
||||
var width = rangeEnd - rangeStart;
|
||||
if (width < bestIntervalWidth)
|
||||
{
|
||||
bestMatch = detail;
|
||||
bestIntervalWidth = width;
|
||||
}
|
||||
}
|
||||
|
||||
return genericHit;
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析卖价明细重量区间,遵循左开右闭 (start, end]。
|
||||
/// 单值如 0.5 兼容为 (0, 0.5];显式区间支持 0,0.5 / 0-0.5。
|
||||
/// </summary>
|
||||
internal static bool TryParseWeightInterval(string? weightText, out decimal start, out decimal end)
|
||||
{
|
||||
start = 0;
|
||||
end = 0;
|
||||
if (string.IsNullOrWhiteSpace(weightText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var text = weightText.Trim()
|
||||
.Trim('[', ']', '(', ')');
|
||||
char[] separators = [',', '-', ',', '~', '~'];
|
||||
var parts = text.Split(separators, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 2
|
||||
&& decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out start)
|
||||
&& decimal.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out end))
|
||||
{
|
||||
return end > start;
|
||||
}
|
||||
|
||||
if (parts.Length == 1
|
||||
&& decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out end)
|
||||
&& end > 0)
|
||||
{
|
||||
start = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static bool WeightInLeftOpenRightClosedInterval(decimal chargeableWeight, decimal start, decimal end) =>
|
||||
chargeableWeight > start && chargeableWeight <= end;
|
||||
|
||||
private static bool ZoneMatches(Dictionary<string, string> factors, IReadOnlyList<string> zoneKeys)
|
||||
{
|
||||
if (!factors.TryGetValue("分区", out var zoneValue) || string.IsNullOrWhiteSpace(zoneValue))
|
||||
if (!TryGetZoneFactorText(factors, out var zoneValue))
|
||||
{
|
||||
if (!factors.TryGetValue("价格分区", out zoneValue) || string.IsNullOrWhiteSpace(zoneValue))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
var expected = zoneValue.Trim();
|
||||
return zoneKeys.Any(key => string.Equals(key.Trim(), expected, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool TryParseWeightRange(
|
||||
Dictionary<string, string> factors,
|
||||
out decimal start,
|
||||
out decimal end)
|
||||
private static bool TryGetZoneFactorText(Dictionary<string, string> factors, out string zoneValue)
|
||||
{
|
||||
start = 0;
|
||||
end = 0;
|
||||
if (!factors.TryGetValue("重量", out var rangeText) || string.IsNullOrWhiteSpace(rangeText))
|
||||
if (TryGetFactorValue(factors, out zoneValue, "分区", "价格分区", "zone"))
|
||||
{
|
||||
return false;
|
||||
return !string.IsNullOrWhiteSpace(zoneValue);
|
||||
}
|
||||
|
||||
var parts = rangeText.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out start))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!decimal.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out end))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
zoneValue = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseSingleWeightTier(Dictionary<string, string> factors, out decimal tier)
|
||||
private static bool TryGetWeightFactorText(Dictionary<string, string> factors, out string weightText) =>
|
||||
TryGetFactorValue(factors, out weightText, "重量", "weight") && !string.IsNullOrWhiteSpace(weightText);
|
||||
|
||||
private static bool MatchCargoTypeFactor(Dictionary<string, string> factors, string cargoLabel)
|
||||
{
|
||||
tier = 0;
|
||||
if (!factors.TryGetValue("重量", out var text) || string.IsNullOrWhiteSpace(text))
|
||||
if (!TryGetFactorValue(factors, out var actual, "货物类型", "cargo") || string.IsNullOrWhiteSpace(actual))
|
||||
{
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (text.Contains('-', StringComparison.Ordinal))
|
||||
return string.Equals(actual.Trim(), cargoLabel, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(actual.Trim(), cargoLabel, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool TryGetFactorValue(
|
||||
Dictionary<string, string> factors,
|
||||
out string value,
|
||||
params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
return false;
|
||||
if (factors.TryGetValue(key, out var hit) && !string.IsNullOrWhiteSpace(hit))
|
||||
{
|
||||
value = hit;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out tier);
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseFactorDictionary(object? factorValuesJson)
|
||||
@ -583,33 +601,37 @@ internal static class ParcelBillingCalculator
|
||||
return string.Equals(actual.Trim(), expected, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool MatchWeightRange(Dictionary<string, string> factors, decimal chargeableWeight)
|
||||
{
|
||||
if (!TryParseWeightRange(factors, out var start, out var end))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return chargeableWeight >= start && chargeableWeight <= end;
|
||||
}
|
||||
|
||||
private static decimal CalculateDetailAmount(
|
||||
ChannelPriceDetailOutput detail,
|
||||
decimal chargeableWeight,
|
||||
CustomerGroup? customerGroup)
|
||||
{
|
||||
var formula = detail.CalculationFormula?.Trim() ?? string.Empty;
|
||||
if (formula is "单价" or "固定价" or "total" or "总价")
|
||||
return NormalizeCalculationFormula(detail.CalculationFormula) switch
|
||||
{
|
||||
return ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0;
|
||||
"unit" or "total" => ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0,
|
||||
"continue" => CalculateContinuePrice(detail, chargeableWeight, customerGroup),
|
||||
_ => detail.CalculatedValue ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算公式字典编码归一化(calculation_formula)。
|
||||
/// </summary>
|
||||
internal static string NormalizeCalculationFormula(string? formula)
|
||||
{
|
||||
var value = formula?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (formula is "续单价" or "续重价")
|
||||
return value.ToLowerInvariant() switch
|
||||
{
|
||||
return CalculateContinuePrice(detail, chargeableWeight, customerGroup);
|
||||
}
|
||||
|
||||
return detail.CalculatedValue ?? 0;
|
||||
"unit" or "单价" => "unit",
|
||||
"total" or "总价" or "固定价" => "total",
|
||||
"continueunit" or "continue_unit" or "continueprice" or "continue_price" or "续单价" or "续重价" => "continue",
|
||||
_ => value.ToLowerInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal CalculateContinuePrice(
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
using Oms.Application.Dtos.Orders;
|
||||
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 单产品查价试算结果(成功或带步骤的失败原因)。
|
||||
/// </summary>
|
||||
internal sealed class PriceCheckAttemptResult
|
||||
{
|
||||
public ParcelQuoteResult? Quote { get; init; }
|
||||
|
||||
public OrderPriceCheckFailureOutput? Failure { get; init; }
|
||||
|
||||
public bool Success => Quote != null;
|
||||
}
|
||||
108
src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
Normal file
108
src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
Normal file
@ -0,0 +1,108 @@
|
||||
using FluentAssertions;
|
||||
using Oms.Application.Dtos.Channels;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Services.Orders;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
|
||||
namespace Oms.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证查价卖价快照匹配:忽略收费时机、unit 公式、重量左开右闭区间。
|
||||
/// </summary>
|
||||
public class PriceCheckSnapshotVerificationTests
|
||||
{
|
||||
private static SalesProductPricePublishSnapshotDto BuildUserSnapshot() => new()
|
||||
{
|
||||
SalesProductId = 1,
|
||||
Prices =
|
||||
[
|
||||
new SalesProductPriceListOutput
|
||||
{
|
||||
Id = 1,
|
||||
Status = 1,
|
||||
CostItem = "freight",
|
||||
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),
|
||||
]
|
||||
},
|
||||
new SalesProductPriceListOutput
|
||||
{
|
||||
Id = 2,
|
||||
Status = 1,
|
||||
CostItem = "handling",
|
||||
ChargingTime = "outbound",
|
||||
Details = [Detail(28, 2, "total", "3", "0,10", 20)]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private static ChannelPriceDetailOutput Detail(
|
||||
int id,
|
||||
int priceId,
|
||||
string formula,
|
||||
string zone,
|
||||
string weight,
|
||||
decimal normalPrice) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
PriceId = priceId,
|
||||
CalculationFormula = formula,
|
||||
CalculatedValue = 0,
|
||||
FactorValuesJson = new Dictionary<string, string>
|
||||
{
|
||||
["分区"] = zone,
|
||||
["重量"] = weight
|
||||
},
|
||||
CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = normalPrice }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void TryParseWeightInterval_SingleValue_ShouldBeLeftOpenRightClosedFromZero()
|
||||
{
|
||||
ParcelBillingCalculator.TryParseWeightInterval("0.5", out var start, out var end).Should().BeTrue();
|
||||
start.Should().Be(0);
|
||||
end.Should().Be(0.5M);
|
||||
ParcelBillingCalculator.WeightInLeftOpenRightClosedInterval(0.5M, start, end).Should().BeTrue();
|
||||
ParcelBillingCalculator.WeightInLeftOpenRightClosedInterval(0M, start, end).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserSnapshot_Zone3_Weight05_ShouldMatchFreight11AndHandling20()
|
||||
{
|
||||
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
|
||||
BuildUserSnapshot(),
|
||||
"PRICE-20260601-001",
|
||||
DateTime.Now,
|
||||
0.5M,
|
||||
["3"],
|
||||
CargoAttributeCodes.General,
|
||||
CustomerGroup.Normal);
|
||||
|
||||
fees.Should().HaveCount(2);
|
||||
fees.Single(x => x.CostItem == "freight").Amount.Should().Be(11M);
|
||||
fees.Single(x => x.CostItem == "handling").Amount.Should().Be(20M);
|
||||
fees.Sum(x => x.Amount).Should().Be(31M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserSnapshot_WrongZone_ShouldReturnNoFees()
|
||||
{
|
||||
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
|
||||
BuildUserSnapshot(),
|
||||
"PRICE-20260601-001",
|
||||
DateTime.Now,
|
||||
0.5M,
|
||||
["99"],
|
||||
CargoAttributeCodes.General,
|
||||
CustomerGroup.Normal);
|
||||
|
||||
fees.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user