查价增强与小包国家码:查价忽略收费时机,支持 unit/total 字典公式与重量左开右闭区间,未命中返回 failures 步骤与原因;收发件人 countryCode 改为 ISO 二字码。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
13ccd198d5
commit
148caae1e7
@ -31,9 +31,9 @@ public class OrderParcelReceiverInput
|
|||||||
public string? Email { get; set; }
|
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>
|
||||||
/// 省份。
|
/// 省份。
|
||||||
|
|||||||
@ -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>
|
||||||
/// 省份。
|
/// 省份。
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
namespace Oms.Application.Dtos.Orders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查价未命中原因(按产品维度)。
|
||||||
|
/// </summary>
|
||||||
|
public class OrderPriceCheckFailureOutput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 销售产品主键。
|
||||||
|
/// </summary>
|
||||||
|
public int ProductId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 产品代码。
|
||||||
|
/// </summary>
|
||||||
|
public string ProductCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 产品名称。
|
||||||
|
/// </summary>
|
||||||
|
public string ProductName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 未命中步骤:product / snapshot / blacklist / weight / zone / price。
|
||||||
|
/// </summary>
|
||||||
|
public string Step { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 未命中原因说明。
|
||||||
|
/// </summary>
|
||||||
|
public string Reason { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
namespace Oms.Application.Dtos.Orders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查价接口返回体。
|
||||||
|
/// </summary>
|
||||||
|
public class OrderPriceCheckResultOutput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 试算成功的产品列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<OrderPriceCheckOutput> Items { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 试算失败或未命中的产品及原因。
|
||||||
|
/// </summary>
|
||||||
|
public List<OrderPriceCheckFailureOutput> Failures { get; set; } = [];
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ using Oms.Domain.CustomersAggregate.Repositories;
|
|||||||
using Oms.Domain.OrdersAggregate.Consts;
|
using Oms.Domain.OrdersAggregate.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,
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
using Oms.Application.Dtos.Orders;
|
||||||
|
|
||||||
|
namespace Oms.Application.Services.Orders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单产品查价试算结果(成功或带步骤的失败原因)。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PriceCheckAttemptResult
|
||||||
|
{
|
||||||
|
public ParcelQuoteResult? Quote { get; init; }
|
||||||
|
|
||||||
|
public OrderPriceCheckFailureOutput? Failure { get; init; }
|
||||||
|
|
||||||
|
public bool Success => Quote != null;
|
||||||
|
}
|
||||||
108
src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
Normal file
108
src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Oms.Application.Dtos.Channels;
|
||||||
|
using Oms.Application.Dtos.Orders;
|
||||||
|
using Oms.Application.Services.Orders;
|
||||||
|
using Oms.Domain.CustomersAggregate.Enums;
|
||||||
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
|
|
||||||
|
namespace Oms.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证查价卖价快照匹配:忽略收费时机、unit 公式、重量左开右闭区间。
|
||||||
|
/// </summary>
|
||||||
|
public class PriceCheckSnapshotVerificationTests
|
||||||
|
{
|
||||||
|
private static SalesProductPricePublishSnapshotDto BuildUserSnapshot() => new()
|
||||||
|
{
|
||||||
|
SalesProductId = 1,
|
||||||
|
Prices =
|
||||||
|
[
|
||||||
|
new SalesProductPriceListOutput
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Status = 1,
|
||||||
|
CostItem = "freight",
|
||||||
|
ChargingTime = "outbound",
|
||||||
|
Details =
|
||||||
|
[
|
||||||
|
Detail(24, 1, "unit", "3", "0,0.5", 11),
|
||||||
|
Detail(25, 1, "unit", "3", "0.5,1", 12),
|
||||||
|
Detail(26, 1, "unit", "3", "1,1.5", 13),
|
||||||
|
Detail(27, 1, "unit", "3", "1.5,2", 14),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
new SalesProductPriceListOutput
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
Status = 1,
|
||||||
|
CostItem = "handling",
|
||||||
|
ChargingTime = "outbound",
|
||||||
|
Details = [Detail(28, 2, "total", "3", "0,10", 20)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ChannelPriceDetailOutput Detail(
|
||||||
|
int id,
|
||||||
|
int priceId,
|
||||||
|
string formula,
|
||||||
|
string zone,
|
||||||
|
string weight,
|
||||||
|
decimal normalPrice) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
PriceId = priceId,
|
||||||
|
CalculationFormula = formula,
|
||||||
|
CalculatedValue = 0,
|
||||||
|
FactorValuesJson = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["分区"] = zone,
|
||||||
|
["重量"] = weight
|
||||||
|
},
|
||||||
|
CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = normalPrice }
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseWeightInterval_SingleValue_ShouldBeLeftOpenRightClosedFromZero()
|
||||||
|
{
|
||||||
|
ParcelBillingCalculator.TryParseWeightInterval("0.5", out var start, out var end).Should().BeTrue();
|
||||||
|
start.Should().Be(0);
|
||||||
|
end.Should().Be(0.5M);
|
||||||
|
ParcelBillingCalculator.WeightInLeftOpenRightClosedInterval(0.5M, start, end).Should().BeTrue();
|
||||||
|
ParcelBillingCalculator.WeightInLeftOpenRightClosedInterval(0M, start, end).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserSnapshot_Zone3_Weight05_ShouldMatchFreight11AndHandling20()
|
||||||
|
{
|
||||||
|
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
|
||||||
|
BuildUserSnapshot(),
|
||||||
|
"PRICE-20260601-001",
|
||||||
|
DateTime.Now,
|
||||||
|
0.5M,
|
||||||
|
["3"],
|
||||||
|
CargoAttributeCodes.General,
|
||||||
|
CustomerGroup.Normal);
|
||||||
|
|
||||||
|
fees.Should().HaveCount(2);
|
||||||
|
fees.Single(x => x.CostItem == "freight").Amount.Should().Be(11M);
|
||||||
|
fees.Single(x => x.CostItem == "handling").Amount.Should().Be(20M);
|
||||||
|
fees.Sum(x => x.Amount).Should().Be(31M);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserSnapshot_WrongZone_ShouldReturnNoFees()
|
||||||
|
{
|
||||||
|
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
|
||||||
|
BuildUserSnapshot(),
|
||||||
|
"PRICE-20260601-001",
|
||||||
|
DateTime.Now,
|
||||||
|
0.5M,
|
||||||
|
["99"],
|
||||||
|
CargoAttributeCodes.General,
|
||||||
|
CustomerGroup.Normal);
|
||||||
|
|
||||||
|
fees.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user