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 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(); var consumedCodes = new HashSet(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 ZoneKeys, bool IsZoneFallback, string? ZoneName); private async Task 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 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(); 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 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> 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 ResolveDestinationCountryCodeAsync(string destinationCountry) { var country = await GetCountryOrThrowAsync(destinationCountry, "目的国不存在"); return country.CountryCode; } /// /// 将国家入参解析为 t_country 记录(支持 ISO 二字码、中文/英文名;兼容数字 Id)。 /// private async Task 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 ResolveCurrentPriceTierKeyAsync() { try { var userId = CurrentUser.GetId(); 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 ResolvePriceCheckCustomerIdAsync() { try { var userId = CurrentUser.GetId(); 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; } } /// /// 查价试算:按国家+邮编匹配分区,在卖价快照中匹配明细。 /// private async Task 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> BuildQuoteZoneAttemptsAsync( SalesProduct product, ProductSnapshotBinding snapshotBinding, ParcelQuoteContext context, PriceCheckInputProfile profile) { var attempts = new List(); 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(); 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(); }