查价增强与小包国家码:查价忽略收费时机,支持 unit/total 字典公式与重量左开右闭区间,未命中返回 failures 步骤与原因;收发件人 countryCode 改为 ISO 二字码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-26 09:57:14 +08:00
parent 13ccd198d5
commit 148caae1e7
10 changed files with 480 additions and 187 deletions

View File

@ -31,9 +31,9 @@ public class OrderParcelReceiverInput
public string? Email { get; set; } public string? Email { get; set; }
/// <summary> /// <summary>
/// 国家/地区代码。 /// 国家/地区二字码(ISO,如 CN、US);兼容传 t_country.Id 数字字符串。
/// </summary> /// </summary>
public int? CountryCode { get; set; } public string? CountryCode { get; set; }
/// <summary> /// <summary>
/// 省份。 /// 省份。

View File

@ -31,9 +31,9 @@ public class OrderParcelSenderInput
public string? Email { get; set; } public string? Email { get; set; }
/// <summary> /// <summary>
/// 国家/地区代码。 /// 国家/地区二字码(ISO,如 CN、US);兼容传 t_country.Id 数字字符串。
/// </summary> /// </summary>
public int? CountryCode { get; set; } public string? CountryCode { get; set; }
/// <summary> /// <summary>
/// 省份。 /// 省份。

View File

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

View File

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

View File

@ -10,6 +10,7 @@ using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.Shared.Enums; using Oms.Domain.Shared.Enums;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories; using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.Orders; namespace Oms.Application.Services.Orders;
@ -52,11 +53,7 @@ public partial class OrderService
} }
var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime); var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
var country = await CountryRepository.FirstOrDefaultAsync(x => x.Id == input.Receiver!.CountryCode); var country = await GetCountryOrThrowAsync(input.Receiver!.CountryCode);
if (country == null)
{
throw new BusinessException("收件国家不存在");
}
if (await IsBlacklistHitAsync( if (await IsBlacklistHitAsync(
product, product,
@ -73,7 +70,7 @@ public partial class OrderService
order = await OrderRepository.InsertAsync(order); order = await OrderRepository.InsertAsync(order);
var weights = BuildInitialWeightRows(order.Id, input); var weights = BuildInitialWeightRows(order.Id, input);
await SaveParcelChildrenAsync(order, input, weights); await SaveParcelChildrenAsync(order, input, weights, country);
try try
{ {
@ -195,7 +192,7 @@ public partial class OrderService
if (input.Receiver == null if (input.Receiver == null
|| string.IsNullOrWhiteSpace(input.Receiver.RecipientsName) || string.IsNullOrWhiteSpace(input.Receiver.RecipientsName)
|| !input.Receiver.CountryCode.HasValue || string.IsNullOrWhiteSpace(input.Receiver.CountryCode)
|| string.IsNullOrWhiteSpace(input.Receiver.ZipCode) || string.IsNullOrWhiteSpace(input.Receiver.ZipCode)
|| string.IsNullOrWhiteSpace(input.Receiver.StreetAddress1)) || 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) 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 var sender = await OrderRepository.InsertSenderAsync(new OrderSender
{ {
OrderId = order.Id, OrderId = order.Id,
SenderName = input.Sender.SenderName, SenderName = input.Sender.SenderName,
Company = input.Sender.Company, Company = input.Sender.Company,
Country = input.Sender.CountryCode, Country = senderCountryId,
Province = input.Sender.Province, Province = input.Sender.Province,
City = input.Sender.City, City = input.Sender.City,
ZipCode = input.Sender.ZipCode, ZipCode = input.Sender.ZipCode,
@ -603,7 +610,7 @@ public partial class OrderService
OrderId = order.Id, OrderId = order.Id,
RecipientsName = input.Receiver.RecipientsName, RecipientsName = input.Receiver.RecipientsName,
CompanyName = input.Receiver.CompanyName, CompanyName = input.Receiver.CompanyName,
Country = input.Receiver.CountryCode, Country = receiverCountry.Id,
Phone = input.Receiver.Phone, Phone = input.Receiver.Phone,
TelePhone = input.Receiver.TelePhone, TelePhone = input.Receiver.TelePhone,
ZipCode = input.Receiver.ZipCode, ZipCode = input.Receiver.ZipCode,

View File

@ -2,14 +2,14 @@ using Fake.Application;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.Shared.Enums; using Oms.Domain.Shared.Enums;
using Oms.Domain.SystemAggregate.Entities;
namespace Oms.Application.Services.Orders; namespace Oms.Application.Services.Orders;
public partial class OrderService public partial class OrderService
{ {
private async Task<List<OrderPriceCheckOutput>> BuildPriceCheckOutputsAsync(OrderPriceCheckInput input) private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input)
{ {
ValidatePriceCheckInput(input); ValidatePriceCheckInput(input);
var bindTime = DateTime.Now; var bindTime = DateTime.Now;
@ -35,41 +35,58 @@ public partial class OrderService
|| x.ProductName.Contains(shippingKeyword) || x.ProductName.Contains(shippingKeyword)
|| x.ProductCode.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 warehouses = await WarehouseAddressRepository.GetListAsync();
var defaultWarehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault(); var defaultWarehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault();
var outputs = new List<OrderPriceCheckOutput>();
foreach (var product in products.OrderBy(x => x.Id)) foreach (var product in products.OrderBy(x => x.Id))
{ {
var quote = await TryComputeProductQuoteAsync(product, quoteContext, customerGroup, bindTime); var attempt = await TryComputePriceCheckQuoteAsync(product, quoteContext, customerGroup, bindTime);
if (quote == null) 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; continue;
} }
var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees); if (attempt.Failure != null)
outputs.Add(new OrderPriceCheckOutput
{ {
Id = quote.ProductId, result.Failures.Add(attempt.Failure);
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
});
} }
return outputs; return result;
} }
private static void ValidatePriceCheckInput(OrderPriceCheckInput input) private static void ValidatePriceCheckInput(OrderPriceCheckInput input)
@ -93,10 +110,45 @@ public partial class OrderService
private async Task<string> ResolveDestinationCountryCodeAsync(string destinationCountry) 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 => var country = await CountryRepository.FirstOrDefaultAsync(x =>
x.CountryCode == trimmed || x.ChineseName == trimmed || x.EnglishName == trimmed); x.CountryCode == upper || x.CountryCode == trimmed);
return country?.CountryCode ?? trimmed.ToUpperInvariant(); 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() private async Task<CustomerGroup?> ResolveCurrentCustomerGroupAsync()
@ -119,68 +171,101 @@ public partial class OrderService
} }
/// <summary> /// <summary>
/// 对单个销售产品执行预报试算(与 parcel/create 同源,不落库)。 /// 查价试算:按国家+邮编匹配分区,在卖价快照中匹配明细(忽略收费时机)。
/// </summary> /// </summary>
private async Task<ParcelQuoteResult?> TryComputeProductQuoteAsync( private async Task<PriceCheckAttemptResult> TryComputePriceCheckQuoteAsync(
SalesProduct product, SalesProduct product,
ParcelQuoteContext context, ParcelQuoteContext context,
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
DateTime bindTime) DateTime bindTime)
{ {
if (product.IsActive != EnableStatus.Enabled)
{
return PriceCheckFailure(product, "product", "产品未启用");
}
ProductSnapshotBinding snapshotBinding;
try try
{ {
if (product.IsActive != EnableStatus.Enabled) snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
{ }
return null; catch (BusinessException ex)
} {
return PriceCheckFailure(product, "snapshot", ex.Message);
}
var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime); if (await IsBlacklistHitAsync(
if (await IsBlacklistHitAsync( product,
product, context.CountryCode,
context.CountryCode, context.Province,
context.Province, context.City,
context.City, context.PostalCode))
context.PostalCode)) {
{ return PriceCheckFailure(
return null; product,
} "blacklist",
$"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 命中产品黑名单");
}
var forecast = ParcelBillingCalculator.BuildForecastWeight(context); var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
try
{
await ApplyChargeableWeightAsync(product, forecast); 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, product,
context.CountryCode, "zone",
context.Province, $"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未匹配产品价格分区(请检查 PriceAreaId 及分区规则)");
context.City, }
context.PostalCode);
if (zoneKeys == null || zoneKeys.Count == 0) 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( return PriceCheckFailure(
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(
product, product,
context.CountryCode, "price",
context.Province, $"计费重 {chargeableWeight}kg,匹配分区键 [{string.Join(", ", zoneKeys)}],货物属性 {context.CargoAttribute}," +
context.City, $"在卖价版本 {snapshotBinding.PriceVersion} 的 {enabledItems} 个启用价格项中未命中任何明细(请检查分区/重量区间/货物类型)");
context.PostalCode); }
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, ProductId = product.Id,
ProductCode = product.ProductCode, ProductCode = product.ProductCode,
@ -188,19 +273,27 @@ public partial class OrderService
RulesVersion = snapshotBinding.RulesVersion, RulesVersion = snapshotBinding.RulesVersion,
PriceVersion = snapshotBinding.PriceVersion, PriceVersion = snapshotBinding.PriceVersion,
VolumetricWeight = forecast.VolumetricWeight ?? 0, VolumetricWeight = forecast.VolumetricWeight ?? 0,
ChargeableWeight = forecast.ChargeableWeight ?? 0, ChargeableWeight = chargeableWeight,
ZoneName = zoneKeys[0], ZoneName = zoneKeys[0],
IsRemote = isRemote, IsRemote = isRemote,
Fees = fees 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();
} }

View File

@ -232,15 +232,14 @@ public partial class OrderService : ApplicationService
/// <returns>订单运费试算列表结果。</returns> /// <returns>订单运费试算列表结果。</returns>
/// <remarks> /// <remarks>
/// 核心业务逻辑: /// 核心业务逻辑:
/// 1. 根据试算参数构造报价请求。 /// 1. 按 bindTime 解析销售产品最新生效卖价/规则快照(与 parcel/create 同源)。
/// 2. 汇总费用、时效等试算结果。 /// 2. 计重、分区匹配后基于卖价快照计算预报阶段应收费用。
/// 3. 按接口约定返回结果列表。 /// 3. 返回试算金额及 rulesVersion、priceVersion。
/// </remarks> /// </remarks>
[HttpPost("/order/price-check")] [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 await BuildPriceCheckResultAsync(input);
return new OmsItemsResult<OrderPriceCheckOutput> { Items = items };
} }
/// <summary> /// <summary>

View File

@ -434,9 +434,8 @@ internal static class ParcelBillingCalculator
decimal chargeableWeight) decimal chargeableWeight)
{ {
var cargoLabel = GetCargoAttributeLabel(cargoAttribute); var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
var tierCandidates = new List<(ChannelPriceDetailOutput Detail, decimal Tier)>(); ChannelPriceDetailOutput? bestMatch = null;
ChannelPriceDetailOutput? rangeHit = null; var bestIntervalWidth = decimal.MaxValue;
ChannelPriceDetailOutput? genericHit = null;
foreach (var detail in details) foreach (var detail in details)
{ {
@ -446,113 +445,132 @@ internal static class ParcelBillingCalculator
continue; continue;
} }
if (!MatchFactorValue(factors, "货物类型", cargoLabel) if (!MatchCargoTypeFactor(factors, cargoLabel))
&& factors.ContainsKey("货物类型"))
{ {
continue; 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; continue;
} }
if (TryParseSingleWeightTier(factors, out var tier)) if (!TryParseWeightInterval(weightText, out var rangeStart, out var rangeEnd))
{ {
tierCandidates.Add((detail, tier));
continue; continue;
} }
genericHit = detail; if (!WeightInLeftOpenRightClosedInterval(chargeableWeight, rangeStart, rangeEnd))
}
if (rangeHit != null)
{
return rangeHit;
}
if (tierCandidates.Count > 0)
{
var ceiling = tierCandidates
.Where(x => x.Tier >= chargeableWeight)
.OrderBy(x => x.Tier)
.Select(x => x.Detail)
.FirstOrDefault();
if (ceiling != null)
{ {
return ceiling; 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) 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(); var expected = zoneValue.Trim();
return zoneKeys.Any(key => string.Equals(key.Trim(), expected, StringComparison.OrdinalIgnoreCase)); return zoneKeys.Any(key => string.Equals(key.Trim(), expected, StringComparison.OrdinalIgnoreCase));
} }
private static bool TryParseWeightRange( private static bool TryGetZoneFactorText(Dictionary<string, string> factors, out string zoneValue)
Dictionary<string, string> factors,
out decimal start,
out decimal end)
{ {
start = 0; if (TryGetFactorValue(factors, out zoneValue, "分区", "价格分区", "zone"))
end = 0;
if (!factors.TryGetValue("重量", out var rangeText) || string.IsNullOrWhiteSpace(rangeText))
{ {
return false; return !string.IsNullOrWhiteSpace(zoneValue);
} }
var parts = rangeText.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); zoneValue = string.Empty;
if (parts.Length != 2) return false;
{
return false;
}
if (!decimal.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out start))
{
return false;
}
if (!decimal.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out end))
{
return false;
}
return true;
} }
private static bool TryParseSingleWeightTier(Dictionary<string, string> factors, out decimal tier) 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 (!TryGetFactorValue(factors, out var actual, "货物类型", "cargo") || string.IsNullOrWhiteSpace(actual))
if (!factors.TryGetValue("重量", out var text) || string.IsNullOrWhiteSpace(text))
{ {
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) private static Dictionary<string, string> ParseFactorDictionary(object? factorValuesJson)
@ -583,33 +601,37 @@ internal static class ParcelBillingCalculator
return string.Equals(actual.Trim(), expected, StringComparison.OrdinalIgnoreCase); 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( private static decimal CalculateDetailAmount(
ChannelPriceDetailOutput detail, ChannelPriceDetailOutput detail,
decimal chargeableWeight, decimal chargeableWeight,
CustomerGroup? customerGroup) CustomerGroup? customerGroup)
{ {
var formula = detail.CalculationFormula?.Trim() ?? string.Empty; return NormalizeCalculationFormula(detail.CalculationFormula) switch
if (formula is "单价" or "固定价" or "total" or "总价")
{ {
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); "unit" or "单价" => "unit",
} "total" or "总价" or "固定价" => "total",
"continueunit" or "continue_unit" or "continueprice" or "continue_price" or "续单价" or "续重价" => "continue",
return detail.CalculatedValue ?? 0; _ => value.ToLowerInvariant()
};
} }
private static decimal CalculateContinuePrice( private static decimal CalculateContinuePrice(

View File

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

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