admin/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
zzh e2443ed6f8 fix: restore country+weight price-check estimates for postcode zones.
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>
2026-07-28 11:14:12 +08:00

784 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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