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