diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelReceiverInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelReceiverInput.cs
index d44ea5c..c15448b 100644
--- a/src/Oms.Application/Dtos/Orders/OrderParcelReceiverInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelReceiverInput.cs
@@ -31,9 +31,9 @@ public class OrderParcelReceiverInput
public string? Email { get; set; }
///
- /// 国家/地区代码。
+ /// 国家/地区二字码(ISO,如 CN、US);兼容传 t_country.Id 数字字符串。
///
- public int? CountryCode { get; set; }
+ public string? CountryCode { get; set; }
///
/// 省份。
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelSenderInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelSenderInput.cs
index afc7ec8..50cbdde 100644
--- a/src/Oms.Application/Dtos/Orders/OrderParcelSenderInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelSenderInput.cs
@@ -31,9 +31,9 @@ public class OrderParcelSenderInput
public string? Email { get; set; }
///
- /// 国家/地区代码。
+ /// 国家/地区二字码(ISO,如 CN、US);兼容传 t_country.Id 数字字符串。
///
- public int? CountryCode { get; set; }
+ public string? CountryCode { get; set; }
///
/// 省份。
diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckFailureOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckFailureOutput.cs
new file mode 100644
index 0000000..e7108c9
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckFailureOutput.cs
@@ -0,0 +1,32 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 查价未命中原因(按产品维度)。
+///
+public class OrderPriceCheckFailureOutput
+{
+ ///
+ /// 销售产品主键。
+ ///
+ public int ProductId { get; set; }
+
+ ///
+ /// 产品代码。
+ ///
+ public string ProductCode { get; set; } = string.Empty;
+
+ ///
+ /// 产品名称。
+ ///
+ public string ProductName { get; set; } = string.Empty;
+
+ ///
+ /// 未命中步骤:product / snapshot / blacklist / weight / zone / price。
+ ///
+ public string Step { get; set; } = string.Empty;
+
+ ///
+ /// 未命中原因说明。
+ ///
+ public string Reason { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckResultOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckResultOutput.cs
new file mode 100644
index 0000000..aa49ad9
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckResultOutput.cs
@@ -0,0 +1,17 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 查价接口返回体。
+///
+public class OrderPriceCheckResultOutput
+{
+ ///
+ /// 试算成功的产品列表。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 试算失败或未命中的产品及原因。
+ ///
+ public List Failures { get; set; } = [];
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 95127ed..5ca83a0 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -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 weights)
+ private async Task SaveParcelChildrenAsync(
+ Order order,
+ OrderParcelCreateInput input,
+ List 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,
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
index cb0cf53..53c5255 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
@@ -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> BuildPriceCheckOutputsAsync(OrderPriceCheckInput input)
+ private async Task 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();
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 ResolveDestinationCountryCodeAsync(string destinationCountry)
{
- var trimmed = destinationCountry.Trim();
+ var country = await GetCountryOrThrowAsync(destinationCountry, "目的国不存在");
+ return country.CountryCode;
+ }
+
+ ///
+ /// 将国家入参解析为 t_country 记录(支持 ISO 二字码、中文/英文名;兼容数字 Id)。
+ ///
+ private async Task 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 ResolveCurrentCustomerGroupAsync()
@@ -119,68 +171,101 @@ public partial class OrderService
}
///
- /// 对单个销售产品执行预报试算(与 parcel/create 同源,不落库)。
+ /// 查价试算:按国家+邮编匹配分区,在卖价快照中匹配明细(忽略收费时机)。
///
- private async Task TryComputeProductQuoteAsync(
+ private async Task 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();
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index 76a5328..5fc0b31 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -232,15 +232,14 @@ public partial class OrderService : ApplicationService
/// 订单运费试算列表结果。
///
/// 核心业务逻辑:
- /// 1. 根据试算参数构造报价请求。
- /// 2. 汇总费用、时效等试算结果。
- /// 3. 按接口约定返回结果列表。
+ /// 1. 按 bindTime 解析销售产品最新生效卖价/规则快照(与 parcel/create 同源)。
+ /// 2. 计重、分区匹配后基于卖价快照计算预报阶段应收费用。
+ /// 3. 返回试算金额及 rulesVersion、priceVersion。
///
[HttpPost("/order/price-check")]
- public async Task> PriceCheckAsync(OrderPriceCheckInput input)
+ public async Task PriceCheckAsync(OrderPriceCheckInput input)
{
- var items = await BuildPriceCheckOutputsAsync(input);
- return new OmsItemsResult { Items = items };
+ return await BuildPriceCheckResultAsync(input);
}
///
diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
index 668ba9a..dce1f3a 100644
--- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
+++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
@@ -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;
}
+ ///
+ /// 解析卖价明细重量区间,遵循左开右闭 (start, end]。
+ /// 单值如 0.5 兼容为 (0, 0.5];显式区间支持 0,0.5 / 0-0.5。
+ ///
+ 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 factors, IReadOnlyList 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 factors,
- out decimal start,
- out decimal end)
+ private static bool TryGetZoneFactorText(Dictionary 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 factors, out decimal tier)
+ private static bool TryGetWeightFactorText(Dictionary factors, out string weightText) =>
+ TryGetFactorValue(factors, out weightText, "重量", "weight") && !string.IsNullOrWhiteSpace(weightText);
+
+ private static bool MatchCargoTypeFactor(Dictionary 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 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 ParseFactorDictionary(object? factorValuesJson)
@@ -583,33 +601,37 @@ internal static class ParcelBillingCalculator
return string.Equals(actual.Trim(), expected, StringComparison.OrdinalIgnoreCase);
}
- private static bool MatchWeightRange(Dictionary 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
+ };
+ }
+
+ ///
+ /// 计算公式字典编码归一化(calculation_formula)。
+ ///
+ 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(
diff --git a/src/Oms.Application/Services/Orders/PriceCheckAttemptResult.cs b/src/Oms.Application/Services/Orders/PriceCheckAttemptResult.cs
new file mode 100644
index 0000000..5f792de
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/PriceCheckAttemptResult.cs
@@ -0,0 +1,15 @@
+using Oms.Application.Dtos.Orders;
+
+namespace Oms.Application.Services.Orders;
+
+///
+/// 单产品查价试算结果(成功或带步骤的失败原因)。
+///
+internal sealed class PriceCheckAttemptResult
+{
+ public ParcelQuoteResult? Quote { get; init; }
+
+ public OrderPriceCheckFailureOutput? Failure { get; init; }
+
+ public bool Success => Quote != null;
+}
diff --git a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
new file mode 100644
index 0000000..5409de9
--- /dev/null
+++ b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
@@ -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;
+
+///
+/// 验证查价卖价快照匹配:忽略收费时机、unit 公式、重量左开右闭区间。
+///
+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
+ {
+ ["分区"] = zone,
+ ["重量"] = weight
+ },
+ CustomerGroupValues = new Dictionary { ["普通客户"] = 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();
+ }
+}