admin/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
zzh 43563b727f feat: 自动触发公式引擎与查价运输方式分组接口
实现中文公式预报前拦截、规则测试 API 及按销售产品分组返回查价运输方式选项。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 18:36:17 +08:00

526 lines
19 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.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();
}