查价增强与小包国家码:查价忽略收费时机,支持 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; }
/// <summary>
/// 国家/地区代码。
/// 国家/地区二字码(ISO,如 CN、US);兼容传 t_country.Id 数字字符串。
/// </summary>
public int? CountryCode { get; set; }
public string? CountryCode { get; set; }
/// <summary>
/// 省份。

View File

@ -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>
/// 省份。

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

View File

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

View File

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

View File

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

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