When rules require postal codes, still quote by same-country snapshot zones as estimated; keep blocking countries not configured at all. Co-authored-by: Cursor <cursoragent@cursor.com>
784 lines
29 KiB
C#
784 lines
29 KiB
C#
using Fake.Application;
|
||
using Oms.Application.Dtos.Marketing;
|
||
using Oms.Application.Dtos.Orders;
|
||
using Oms.Application.Shared;
|
||
using Oms.Domain.ChannelsAggregate.Entities;
|
||
using Oms.Domain.OrdersAggregate.Consts;
|
||
using Oms.Domain.OrdersAggregate.Entities;
|
||
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 const string DomesticPickupGroupNameKeyword = "国内揽收";
|
||
|
||
private sealed record PriceCheckCargoTarget(string Code, string DisplayName, string CargoAttribute);
|
||
|
||
private 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 domesticPickupGroupCodes = dictRows
|
||
.Where(x => !string.IsNullOrWhiteSpace(x.DictName)
|
||
&& x.DictName.Trim().Contains(DomesticPickupGroupNameKeyword))
|
||
.Select(x => x.DictCode?.Trim())
|
||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
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)
|
||
|| domesticPickupGroupCodes.Contains(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)
|
||
|| domesticPickupGroupCodes.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 sealed record QuoteZoneAttempt(
|
||
IReadOnlyList<string> ZoneKeys,
|
||
bool IsZoneFallback,
|
||
string? ZoneName);
|
||
|
||
private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input)
|
||
{
|
||
ValidatePriceCheckInput(input);
|
||
var profile = PriceCheckInputProfile.FromInput(input);
|
||
var bindTime = DateTime.Now;
|
||
var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry);
|
||
var cargoTargets = ResolvePriceCheckCargoTargets(input);
|
||
var warehouses = await ResolvePriceCheckWarehousesAsync(input);
|
||
|
||
var dimensionLength = profile.HasDimensions ? input.Length!.Value : 0M;
|
||
var dimensionWidth = profile.HasDimensions ? input.Width!.Value : 0M;
|
||
var dimensionHeight = profile.HasDimensions ? input.Height!.Value : 0M;
|
||
|
||
var baseContext = new ParcelQuoteContext
|
||
{
|
||
CountryCode = countryCode,
|
||
Province = input.DestinationProvince,
|
||
PostalCode = input.PostalCode,
|
||
Length = dimensionLength,
|
||
Width = dimensionWidth,
|
||
Height = dimensionHeight,
|
||
ActualWeight = input.CargoWeight!.Value
|
||
};
|
||
|
||
var priceTierKey = await ResolveCurrentPriceTierKeyAsync();
|
||
var shippingKeyword = input.ShippingMethod?.Trim();
|
||
List<SalesProduct> products;
|
||
if (string.IsNullOrWhiteSpace(shippingKeyword))
|
||
{
|
||
products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled);
|
||
}
|
||
else
|
||
{
|
||
var exact = await SalesProductRepository.GetByProductCodeAsync(shippingKeyword);
|
||
if (exact != null && exact.IsActive == EnableStatus.Enabled)
|
||
{
|
||
products = [exact];
|
||
}
|
||
else
|
||
{
|
||
products = await SalesProductRepository.GetListAsync(x =>
|
||
x.IsActive == EnableStatus.Enabled &&
|
||
(x.ProductName.Contains(shippingKeyword) ||
|
||
x.ProductCode.Contains(shippingKeyword)));
|
||
}
|
||
}
|
||
|
||
// 查价页面不展示「国内揽收」分组的产品
|
||
var domesticPickupGroupCodes = (await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType))
|
||
.Where(x => !string.IsNullOrWhiteSpace(x.DictName)
|
||
&& x.DictName.Trim().Contains(DomesticPickupGroupNameKeyword))
|
||
.Select(x => x.DictCode?.Trim())
|
||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
|
||
if (domesticPickupGroupCodes.Count > 0)
|
||
{
|
||
products = products
|
||
.Where(p =>
|
||
{
|
||
var groupCode = string.IsNullOrWhiteSpace(p.ProductGroup)
|
||
? UngroupedProductGroupCode
|
||
: p.ProductGroup.Trim();
|
||
return !domesticPickupGroupCodes.Contains(groupCode);
|
||
})
|
||
.ToList();
|
||
}
|
||
|
||
var result = new OrderPriceCheckResultOutput
|
||
{
|
||
QueryHint = PriceCheckHintBuilder.BuildQueryHint(profile)
|
||
};
|
||
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,
|
||
profile,
|
||
priceTierKey,
|
||
bindTime,
|
||
cargo.DisplayName,
|
||
warehouse.WarehouseName);
|
||
|
||
if (attempt.Success)
|
||
{
|
||
var quote = attempt.Quote!;
|
||
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
|
||
var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback);
|
||
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,
|
||
QuoteLevel = itemHint.QuoteLevel,
|
||
MissingFieldCodes = itemHint.MissingFieldCodes,
|
||
Disclaimers = itemHint.Disclaimers,
|
||
ZoneName = quote.ZoneName
|
||
});
|
||
continue;
|
||
}
|
||
|
||
if (attempt.Failure != null)
|
||
{
|
||
result.Failures.Add(attempt.Failure);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
await ApplyMarketingToPriceCheckResultAsync(input, result);
|
||
return result;
|
||
}
|
||
|
||
private async Task ApplyMarketingToPriceCheckResultAsync(OrderPriceCheckInput input, OrderPriceCheckResultOutput result)
|
||
{
|
||
if (result.Items.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var marketingHelper = LazyServiceProvider.GetRequiredService<MarketingOrderHelper>();
|
||
MarketingPriceBreakdown? firstBreakdown = null;
|
||
decimal? amountAfterLevel = null;
|
||
|
||
foreach (var item in result.Items)
|
||
{
|
||
var breakdown = await marketingHelper.BuildBreakdownAsync(
|
||
input.CustomerId,
|
||
item.TotalCost,
|
||
input.CouponInstanceId);
|
||
item.PriceBreakdown = breakdown;
|
||
item.TotalCost = breakdown.PayableAmount;
|
||
item.TotalCostNum = breakdown.PayableAmount;
|
||
firstBreakdown ??= breakdown;
|
||
amountAfterLevel ??= breakdown.AmountAfterLevel;
|
||
}
|
||
|
||
result.PriceBreakdown = firstBreakdown;
|
||
if (input.CustomerId is > 0)
|
||
{
|
||
result.AvailableCoupons = await marketingHelper.BuildAvailableCouponsAsync(
|
||
input.CustomerId,
|
||
amountAfterLevel);
|
||
}
|
||
}
|
||
|
||
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)
|
||
{
|
||
return
|
||
[
|
||
BuildCargoTarget(CargoAttributeCodes.General),
|
||
BuildCargoTarget(CargoAttributeCodes.Electronics),
|
||
BuildCargoTarget(CargoAttributeCodes.Sensitive)
|
||
];
|
||
}
|
||
|
||
return codes.Select(code =>
|
||
{
|
||
var attribute = ParcelBillingCalculator.MapCargoTypeToAttribute(code);
|
||
var display = ParcelBillingCalculator.GetCargoAttributeLabel(attribute);
|
||
return new PriceCheckCargoTarget(code, display, attribute);
|
||
}).ToList();
|
||
}
|
||
|
||
private static PriceCheckCargoTarget BuildCargoTarget(string cargoAttribute) =>
|
||
new(
|
||
cargoAttribute,
|
||
ParcelBillingCalculator.GetCargoAttributeLabel(cargoAttribute),
|
||
cargoAttribute);
|
||
|
||
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");
|
||
}
|
||
|
||
ParcelBillingValidators.EnsurePriceCheckDimensions(input.Length, input.Width, input.Height);
|
||
}
|
||
|
||
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<string?> ResolveCurrentPriceTierKeyAsync()
|
||
{
|
||
try
|
||
{
|
||
var userId = CurrentUser.GetId<int>();
|
||
if (userId <= 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == userId);
|
||
if (customer == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return await ResolvePriceTierKeyAsync(customer.Id);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private async Task<int?> ResolvePriceCheckCustomerIdAsync()
|
||
{
|
||
try
|
||
{
|
||
var userId = CurrentUser.GetId<int>();
|
||
if (userId <= 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == userId);
|
||
if (customer == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(CustomerRepository, customer);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查价试算:按国家+邮编匹配分区,在卖价快照中匹配明细。
|
||
/// </summary>
|
||
private async Task<PriceCheckAttemptResult> TryComputePriceCheckQuoteAsync(
|
||
SalesProduct product,
|
||
ParcelQuoteContext context,
|
||
PriceCheckInputProfile profile,
|
||
string? priceTierKey,
|
||
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 (profile.HasPostalCode
|
||
&& await IsBlacklistHitAsync(
|
||
product,
|
||
snapshotBinding.Rules,
|
||
context.CountryCode,
|
||
context.Province,
|
||
context.City,
|
||
context.PostalCode))
|
||
{
|
||
return PriceCheckFailure(
|
||
product,
|
||
"blacklist",
|
||
$"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 命中产品黑名单",
|
||
cargoTypeName,
|
||
warehouseName);
|
||
}
|
||
|
||
OrderWeight forecast;
|
||
try
|
||
{
|
||
if (profile.HasDimensions)
|
||
{
|
||
forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
|
||
}
|
||
else
|
||
{
|
||
forecast = new OrderWeight
|
||
{
|
||
Type = OrderWeightTypeCodes.Forecast,
|
||
ActualWeight = context.ActualWeight
|
||
};
|
||
await ApplyQuoteChargeableWeightFromActualOnlyAsync(product, snapshotBinding.Rules, forecast);
|
||
}
|
||
}
|
||
catch (BusinessException ex)
|
||
{
|
||
return PriceCheckFailure(product, "weight", ex.Message, cargoTypeName, warehouseName);
|
||
}
|
||
|
||
var zoneAttempts = await BuildQuoteZoneAttemptsAsync(product, snapshotBinding, context, profile);
|
||
if (zoneAttempts.Count == 0)
|
||
{
|
||
return PriceCheckFailure(
|
||
product,
|
||
"zone",
|
||
profile.HasPostalCode
|
||
? $"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格分区"
|
||
: $"目的国 {context.CountryCode} 未匹配到可参考的价格分区",
|
||
cargoTypeName,
|
||
warehouseName);
|
||
}
|
||
|
||
var chargeableWeight = forecast.ChargeableWeight ?? 0;
|
||
ParcelQuoteResult? bestQuote = null;
|
||
decimal bestTotal = decimal.MaxValue;
|
||
|
||
foreach (var zoneAttempt in zoneAttempts)
|
||
{
|
||
var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
|
||
snapshotBinding.Price,
|
||
snapshotBinding.PriceVersion,
|
||
snapshotBinding.PriceEffectiveTime,
|
||
chargeableWeight,
|
||
zoneAttempt.ZoneKeys,
|
||
context.CargoAttribute,
|
||
priceTierKey,
|
||
context.WarehouseId,
|
||
context.WarehouseName);
|
||
if (feeLines.Count == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var total = feeLines.Sum(x => x.Fee.Amount);
|
||
if (total >= bestTotal)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var isRemote = profile.HasPostalCode
|
||
&& await IsRemoteHitAsync(
|
||
product,
|
||
snapshotBinding.Rules,
|
||
context.CountryCode,
|
||
context.Province,
|
||
context.City,
|
||
context.PostalCode);
|
||
|
||
bestTotal = total;
|
||
bestQuote = new ParcelQuoteResult
|
||
{
|
||
ProductId = product.Id,
|
||
ProductCode = product.ProductCode,
|
||
ProductName = product.ProductName,
|
||
RulesVersion = snapshotBinding.RulesVersion,
|
||
PriceVersion = snapshotBinding.PriceVersion,
|
||
VolumetricWeight = forecast.VolumetricWeight ?? 0,
|
||
ChargeableWeight = chargeableWeight,
|
||
ZoneName = zoneAttempt.ZoneName ?? zoneAttempt.ZoneKeys.FirstOrDefault(),
|
||
IsRemote = isRemote,
|
||
UsedZoneFallback = zoneAttempt.IsZoneFallback,
|
||
FeeLines = feeLines
|
||
};
|
||
}
|
||
|
||
if (bestQuote == null)
|
||
{
|
||
if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
|
||
{
|
||
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项", cargoTypeName, warehouseName);
|
||
}
|
||
|
||
var fallbackZoneKeys = zoneAttempts[0].ZoneKeys;
|
||
return PriceCheckFailure(
|
||
product,
|
||
"price",
|
||
ParcelBillingCalculator.ExplainQuotePriceMiss(
|
||
snapshotBinding.Price,
|
||
chargeableWeight,
|
||
fallbackZoneKeys,
|
||
context.CargoAttribute,
|
||
priceTierKey,
|
||
context.WarehouseId,
|
||
context.WarehouseName),
|
||
cargoTypeName,
|
||
warehouseName);
|
||
}
|
||
|
||
return new PriceCheckAttemptResult { Quote = bestQuote };
|
||
}
|
||
|
||
private async Task<List<QuoteZoneAttempt>> BuildQuoteZoneAttemptsAsync(
|
||
SalesProduct product,
|
||
ProductSnapshotBinding snapshotBinding,
|
||
ParcelQuoteContext context,
|
||
PriceCheckInputProfile profile)
|
||
{
|
||
var attempts = new List<QuoteZoneAttempt>();
|
||
|
||
async Task TryAddAsync(string? province, string? zipCode, bool isFallback)
|
||
{
|
||
var keys = await ResolvePriceZoneKeysAsync(
|
||
product,
|
||
snapshotBinding.Rules,
|
||
snapshotBinding.Price,
|
||
context.CountryCode,
|
||
province,
|
||
context.City,
|
||
zipCode);
|
||
if (keys is not { Count: > 0 })
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (attempts.Any(x => x.ZoneKeys.SequenceEqual(keys, StringComparer.OrdinalIgnoreCase)))
|
||
{
|
||
return;
|
||
}
|
||
|
||
attempts.Add(new QuoteZoneAttempt(keys, isFallback, keys[0]));
|
||
}
|
||
|
||
if (profile.HasPostalCode || profile.HasProvince)
|
||
{
|
||
await TryAddAsync(
|
||
profile.HasProvince ? context.Province : null,
|
||
profile.HasPostalCode ? context.PostalCode : null,
|
||
false);
|
||
return attempts;
|
||
}
|
||
|
||
await TryAddAsync(null, null, false);
|
||
|
||
// 精确命中(如无邮编区间的国家级分区)后,仍可用快照内其它分区键作参考兜底
|
||
if (attempts.Count > 0)
|
||
{
|
||
foreach (var zoneKey in ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price))
|
||
{
|
||
if (attempts.Any(x => x.ZoneKeys.Any(k => string.Equals(k, zoneKey, StringComparison.OrdinalIgnoreCase))))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
attempts.Add(new QuoteZoneAttempt([zoneKey], true, zoneKey));
|
||
}
|
||
|
||
return attempts;
|
||
}
|
||
|
||
// 仅国家+重量:分区规则带邮编时无法精确命中,按「同国家」分区给出估算价;
|
||
// 快照中完全没有该国家时不跨国家兜底。
|
||
if (snapshotBinding.Price?.PriceAreaInfos is { Count: > 0 })
|
||
{
|
||
foreach (var zoneKey in ParcelBillingCalculator.CollectCountryEstimateZoneKeys(
|
||
snapshotBinding.Price,
|
||
context.CountryCode))
|
||
{
|
||
attempts.Add(new QuoteZoneAttempt([zoneKey], true, zoneKey));
|
||
}
|
||
|
||
return attempts;
|
||
}
|
||
|
||
// 无快照分区明细时,退回库表:同国家且不要求邮编的规则
|
||
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price);
|
||
if (priceAreaId is > 0)
|
||
{
|
||
var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value);
|
||
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value);
|
||
foreach (var rule in rules)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(rule.CountryCode)
|
||
&& !string.Equals(rule.CountryCode, context.CountryCode, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(rule.Province) || !string.IsNullOrWhiteSpace(rule.City))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var keys = ParcelBillingCalculator.BuildPriceZoneKeys(rule, area);
|
||
if (keys.Count == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (attempts.Any(x => x.ZoneKeys.SequenceEqual(keys, StringComparer.OrdinalIgnoreCase)))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
attempts.Add(new QuoteZoneAttempt(keys.ToList(), true, keys[0]));
|
||
}
|
||
}
|
||
|
||
return attempts;
|
||
}
|
||
|
||
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();
|
||
}
|