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 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(); var consumedCodes = new HashSet(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 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 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> 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 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 ResolveCurrentCustomerGroupAsync() { try { var userId = CurrentUser.GetId(); if (userId <= 0) { return null; } var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == userId); return customer?.CustomerGroup; } catch { return null; } } /// /// 查价试算:按国家+邮编匹配分区,在卖价快照中匹配明细。 /// private async Task 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(); 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(); }