实现中文公式预报前拦截、规则测试 API 及按销售产品分组返回查价运输方式选项。 Co-authored-by: Cursor <cursoragent@cursor.com>
526 lines
19 KiB
C#
526 lines
19 KiB
C#
using Fake.Application;
|
||
using Oms.Application.Dtos.Orders;
|
||
using Oms.Domain.ChannelsAggregate.Entities;
|
||
using Oms.Domain.CustomersAggregate.Enums;
|
||
using Oms.Domain.Shared.Enums;
|
||
using Oms.Domain.SystemAggregate.Entities;
|
||
|
||
namespace Oms.Application.Services.Orders;
|
||
|
||
public partial class OrderService
|
||
{
|
||
private const string SalesProductGroupDictType = "sales_product_group";
|
||
private const string UngroupedProductGroupCode = "_ungrouped";
|
||
|
||
private sealed record PriceCheckCargoTarget(string Code, string DisplayName, string CargoAttribute);
|
||
|
||
public async Task<OrderPriceCheckShippingOptionsOutput> BuildPriceCheckShippingOptionsAsync()
|
||
{
|
||
var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled);
|
||
var sortedProducts = products
|
||
.OrderBy(x => x.ProductName)
|
||
.ThenBy(x => x.ProductCode)
|
||
.ToList();
|
||
|
||
var dictRows = await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType);
|
||
var dictOrder = dictRows
|
||
.OrderBy(x => x.DictSort)
|
||
.ThenBy(x => x.DictCode)
|
||
.ToList();
|
||
|
||
var grouped = sortedProducts
|
||
.GroupBy(
|
||
x => string.IsNullOrWhiteSpace(x.ProductGroup) ? UngroupedProductGroupCode : x.ProductGroup.Trim(),
|
||
StringComparer.OrdinalIgnoreCase)
|
||
.ToDictionary(x => x.Key, x => x.ToList(), StringComparer.OrdinalIgnoreCase);
|
||
|
||
var groups = new List<OrderPriceCheckShippingGroupOutput>();
|
||
var consumedCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (var dict in dictOrder)
|
||
{
|
||
var code = dict.DictCode?.Trim();
|
||
if (string.IsNullOrWhiteSpace(code) || !grouped.TryGetValue(code, out var list) || list.Count == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
consumedCodes.Add(code);
|
||
groups.Add(new OrderPriceCheckShippingGroupOutput
|
||
{
|
||
GroupCode = code,
|
||
GroupName = string.IsNullOrWhiteSpace(dict.DictName) ? code : dict.DictName.Trim(),
|
||
Products = list.Select(MapPriceCheckShippingOption).ToList()
|
||
});
|
||
}
|
||
|
||
foreach (var pair in grouped.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
if (pair.Key.Equals(UngroupedProductGroupCode, StringComparison.OrdinalIgnoreCase)
|
||
|| consumedCodes.Contains(pair.Key)
|
||
|| pair.Value.Count == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
groups.Add(new OrderPriceCheckShippingGroupOutput
|
||
{
|
||
GroupCode = pair.Key,
|
||
GroupName = pair.Key,
|
||
Products = pair.Value.Select(MapPriceCheckShippingOption).ToList()
|
||
});
|
||
}
|
||
|
||
if (grouped.TryGetValue(UngroupedProductGroupCode, out var ungrouped) && ungrouped.Count > 0)
|
||
{
|
||
groups.Add(new OrderPriceCheckShippingGroupOutput
|
||
{
|
||
GroupCode = UngroupedProductGroupCode,
|
||
GroupName = "未分组",
|
||
Products = ungrouped.Select(MapPriceCheckShippingOption).ToList()
|
||
});
|
||
}
|
||
|
||
return new OrderPriceCheckShippingOptionsOutput { Groups = groups };
|
||
}
|
||
|
||
private static OrderPriceCheckShippingOptionOutput MapPriceCheckShippingOption(SalesProduct product)
|
||
{
|
||
var code = product.ProductCode?.Trim() ?? string.Empty;
|
||
var name = product.ProductName?.Trim() ?? string.Empty;
|
||
return new OrderPriceCheckShippingOptionOutput
|
||
{
|
||
ProductId = product.Id,
|
||
ProductCode = code,
|
||
ProductName = name,
|
||
Label = string.IsNullOrWhiteSpace(name) ? code : $"{name} {code}"
|
||
};
|
||
}
|
||
|
||
private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input)
|
||
{
|
||
ValidatePriceCheckInput(input);
|
||
var bindTime = DateTime.Now;
|
||
var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry);
|
||
var cargoTargets = ResolvePriceCheckCargoTargets(input);
|
||
var warehouses = await ResolvePriceCheckWarehousesAsync(input);
|
||
|
||
var baseContext = new ParcelQuoteContext
|
||
{
|
||
CountryCode = countryCode,
|
||
Province = input.DestinationProvince,
|
||
PostalCode = input.PostalCode,
|
||
Length = input.Length!.Value,
|
||
Width = input.Width!.Value,
|
||
Height = input.Height!.Value,
|
||
ActualWeight = input.CargoWeight!.Value
|
||
};
|
||
|
||
var customerGroup = await ResolveCurrentCustomerGroupAsync();
|
||
var shippingKeyword = input.ShippingMethod?.Trim();
|
||
var products = await SalesProductRepository.GetListAsync(x =>
|
||
x.IsActive == EnableStatus.Enabled &&
|
||
(string.IsNullOrWhiteSpace(shippingKeyword)
|
||
|| 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;
|
||
}
|
||
|
||
foreach (var product in products.OrderBy(x => x.Id))
|
||
{
|
||
foreach (var cargo in cargoTargets)
|
||
{
|
||
foreach (var warehouse in warehouses)
|
||
{
|
||
var quoteContext = new ParcelQuoteContext
|
||
{
|
||
CountryCode = baseContext.CountryCode,
|
||
Province = baseContext.Province,
|
||
PostalCode = baseContext.PostalCode,
|
||
CargoAttribute = cargo.CargoAttribute,
|
||
CargoTypeCode = cargo.Code,
|
||
WarehouseId = warehouse.Id,
|
||
WarehouseName = warehouse.WarehouseName,
|
||
Length = baseContext.Length,
|
||
Width = baseContext.Width,
|
||
Height = baseContext.Height,
|
||
ActualWeight = baseContext.ActualWeight
|
||
};
|
||
|
||
var attempt = await TryComputePriceCheckQuoteAsync(
|
||
product,
|
||
quoteContext,
|
||
customerGroup,
|
||
bindTime,
|
||
cargo.DisplayName,
|
||
warehouse.WarehouseName);
|
||
|
||
if (attempt.Success)
|
||
{
|
||
var quote = attempt.Quote!;
|
||
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
|
||
result.Items.Add(new OrderPriceCheckOutput
|
||
{
|
||
Id = quote.ProductId,
|
||
ProductCode = quote.ProductCode,
|
||
ShippingMethod = quote.ProductName,
|
||
CargoType = cargo.Code,
|
||
CargoTypeName = cargo.DisplayName,
|
||
WarehouseId = warehouse.Id,
|
||
Warehouse = warehouse.WarehouseName,
|
||
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
|
||
VolumetricWeight = quote.VolumetricWeight,
|
||
ChargeableWeight = quote.ChargeableWeight,
|
||
CostFormula = summary.CostFormula,
|
||
Freight = summary.Freight,
|
||
OtherFees = summary.OtherFees,
|
||
OtherFeeList = summary.OtherFeeList,
|
||
TotalCost = summary.TotalCost,
|
||
MaxDeliveryDays = product.MaxTimeliness,
|
||
TotalCostNum = summary.TotalCost,
|
||
RulesVersion = quote.RulesVersion,
|
||
PriceVersion = quote.PriceVersion,
|
||
IsRemote = quote.IsRemote
|
||
});
|
||
continue;
|
||
}
|
||
|
||
if (attempt.Failure != null)
|
||
{
|
||
result.Failures.Add(attempt.Failure);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static List<PriceCheckCargoTarget> ResolvePriceCheckCargoTargets(OrderPriceCheckInput input)
|
||
{
|
||
var codes = (input.CargoTypes ?? [])
|
||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||
.Select(x => x.Trim())
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
if (codes.Count == 0 && !string.IsNullOrWhiteSpace(input.CargoType))
|
||
{
|
||
codes.Add(input.CargoType.Trim());
|
||
}
|
||
|
||
if (codes.Count == 0)
|
||
{
|
||
codes.Add(string.Empty);
|
||
}
|
||
|
||
return codes.Select(code =>
|
||
{
|
||
var attribute = ParcelBillingCalculator.MapCargoTypeToAttribute(code);
|
||
var display = ParcelBillingCalculator.GetCargoAttributeLabel(attribute);
|
||
return new PriceCheckCargoTarget(code, display, attribute);
|
||
}).ToList();
|
||
}
|
||
|
||
private async Task<List<WarehouseAddress>> ResolvePriceCheckWarehousesAsync(OrderPriceCheckInput input)
|
||
{
|
||
var ids = (input.WarehouseIds ?? [])
|
||
.Where(x => x > 0)
|
||
.Distinct()
|
||
.ToList();
|
||
|
||
if (ids.Count > 0)
|
||
{
|
||
var selected = await WarehouseAddressRepository.GetListAsync(x => ids.Contains(x.Id));
|
||
return selected.OrderBy(x => x.Id).ToList();
|
||
}
|
||
|
||
var all = await WarehouseAddressRepository.GetListAsync();
|
||
return all.OrderBy(x => x.Id).ToList();
|
||
}
|
||
|
||
private static void ValidatePriceCheckInput(OrderPriceCheckInput input)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(input.DestinationCountry))
|
||
{
|
||
throw new BusinessException("目的国不能为空");
|
||
}
|
||
|
||
if (!input.CargoWeight.HasValue || input.CargoWeight <= 0)
|
||
{
|
||
throw new BusinessException("货物重量必须大于0");
|
||
}
|
||
|
||
if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue
|
||
|| input.Length <= 0 || input.Width <= 0 || input.Height <= 0)
|
||
{
|
||
throw new BusinessException("长宽高必须大于0");
|
||
}
|
||
}
|
||
|
||
private async Task<string> ResolveDestinationCountryCodeAsync(string destinationCountry)
|
||
{
|
||
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 == 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()
|
||
{
|
||
try
|
||
{
|
||
var userId = CurrentUser.GetId<int>();
|
||
if (userId <= 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == userId);
|
||
return customer?.CustomerGroup;
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查价试算:按国家+邮编匹配分区,在卖价快照中匹配明细。
|
||
/// </summary>
|
||
private async Task<PriceCheckAttemptResult> TryComputePriceCheckQuoteAsync(
|
||
SalesProduct product,
|
||
ParcelQuoteContext context,
|
||
CustomerGroup? customerGroup,
|
||
DateTime bindTime,
|
||
string cargoTypeName,
|
||
string warehouseName)
|
||
{
|
||
if (product.IsActive != EnableStatus.Enabled)
|
||
{
|
||
return PriceCheckFailure(product, "product", "产品未启用", cargoTypeName, warehouseName);
|
||
}
|
||
|
||
ProductSnapshotBinding snapshotBinding;
|
||
try
|
||
{
|
||
snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
|
||
}
|
||
catch (BusinessException ex)
|
||
{
|
||
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
|
||
}
|
||
|
||
if (await IsBlacklistHitAsync(
|
||
product,
|
||
snapshotBinding.Rules,
|
||
context.CountryCode,
|
||
context.Province,
|
||
context.City,
|
||
context.PostalCode))
|
||
{
|
||
return PriceCheckFailure(
|
||
product,
|
||
"blacklist",
|
||
$"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 命中产品黑名单",
|
||
cargoTypeName,
|
||
warehouseName);
|
||
}
|
||
|
||
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
||
try
|
||
{
|
||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
|
||
}
|
||
catch (BusinessException ex)
|
||
{
|
||
return PriceCheckFailure(product, "weight", ex.Message, cargoTypeName, warehouseName);
|
||
}
|
||
|
||
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price);
|
||
if (!priceAreaId.HasValue)
|
||
{
|
||
var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
|
||
var snapshotZoneHint = snapshotZones.Count > 0
|
||
? $"卖价明细分区=[{string.Join(", ", snapshotZones)}],"
|
||
: string.Empty;
|
||
return PriceCheckFailure(
|
||
product,
|
||
"zone",
|
||
$"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键",
|
||
cargoTypeName,
|
||
warehouseName);
|
||
}
|
||
|
||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
||
product,
|
||
snapshotBinding.Rules,
|
||
snapshotBinding.Price,
|
||
context.CountryCode,
|
||
context.Province,
|
||
context.City,
|
||
context.PostalCode);
|
||
if (zoneKeys == null || zoneKeys.Count == 0)
|
||
{
|
||
var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
|
||
var snapshotZoneHint = snapshotZones.Count > 0
|
||
? $"卖价明细分区=[{string.Join(", ", snapshotZones)}]。"
|
||
: string.Empty;
|
||
return PriceCheckFailure(
|
||
product,
|
||
"zone",
|
||
$"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格快照内分区规则(PriceAreaId={priceAreaId})," +
|
||
"请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致",
|
||
cargoTypeName,
|
||
warehouseName);
|
||
}
|
||
|
||
var chargeableWeight = forecast.ChargeableWeight ?? 0;
|
||
var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
|
||
snapshotBinding.Price,
|
||
snapshotBinding.PriceVersion,
|
||
snapshotBinding.PriceEffectiveTime,
|
||
chargeableWeight,
|
||
zoneKeys,
|
||
context.CargoAttribute,
|
||
customerGroup,
|
||
context.WarehouseId,
|
||
context.WarehouseName);
|
||
|
||
if (feeLines.Count == 0)
|
||
{
|
||
if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
|
||
{
|
||
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项", cargoTypeName, warehouseName);
|
||
}
|
||
|
||
return PriceCheckFailure(
|
||
product,
|
||
"price",
|
||
ParcelBillingCalculator.ExplainQuotePriceMiss(
|
||
snapshotBinding.Price,
|
||
chargeableWeight,
|
||
zoneKeys,
|
||
context.CargoAttribute,
|
||
customerGroup,
|
||
context.WarehouseId,
|
||
context.WarehouseName),
|
||
cargoTypeName,
|
||
warehouseName);
|
||
}
|
||
|
||
var isRemote = await IsRemoteHitAsync(
|
||
product,
|
||
snapshotBinding.Rules,
|
||
context.CountryCode,
|
||
context.Province,
|
||
context.City,
|
||
context.PostalCode);
|
||
|
||
return new PriceCheckAttemptResult
|
||
{
|
||
Quote = new ParcelQuoteResult
|
||
{
|
||
ProductId = product.Id,
|
||
ProductCode = product.ProductCode,
|
||
ProductName = product.ProductName,
|
||
RulesVersion = snapshotBinding.RulesVersion,
|
||
PriceVersion = snapshotBinding.PriceVersion,
|
||
VolumetricWeight = forecast.VolumetricWeight ?? 0,
|
||
ChargeableWeight = chargeableWeight,
|
||
ZoneName = zoneKeys[0],
|
||
IsRemote = isRemote,
|
||
FeeLines = feeLines
|
||
}
|
||
};
|
||
}
|
||
|
||
private static PriceCheckAttemptResult PriceCheckFailure(
|
||
SalesProduct product,
|
||
string step,
|
||
string reason,
|
||
string? cargoTypeName = null,
|
||
string? warehouseName = null) =>
|
||
new()
|
||
{
|
||
Failure = new OrderPriceCheckFailureOutput
|
||
{
|
||
ProductId = product.Id,
|
||
ProductCode = product.ProductCode,
|
||
ProductName = product.ProductName,
|
||
Step = step,
|
||
CargoTypeName = cargoTypeName,
|
||
WarehouseName = warehouseName,
|
||
Reason = PrefixPriceCheckFailureReason(cargoTypeName, warehouseName, reason)
|
||
}
|
||
};
|
||
|
||
private static string PrefixPriceCheckFailureReason(
|
||
string? cargoTypeName,
|
||
string? warehouseName,
|
||
string reason)
|
||
{
|
||
var parts = new List<string>();
|
||
if (!string.IsNullOrWhiteSpace(cargoTypeName))
|
||
{
|
||
parts.Add($"货物类型={cargoTypeName.Trim()}");
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(warehouseName))
|
||
{
|
||
parts.Add($"仓库={warehouseName.Trim()}");
|
||
}
|
||
|
||
return parts.Count == 0 ? reason : $"[{string.Join(",", parts)}] {reason}";
|
||
}
|
||
|
||
private static string FormatPostalCode(string? postalCode) =>
|
||
string.IsNullOrWhiteSpace(postalCode) ? "-" : postalCode.Trim();
|
||
}
|