using Fake.Application; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Orders; using Oms.Application.Services.Orders.Issues; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.SystemAggregate.Repositories; namespace Oms.Application.Services.Channels; public partial class SalesProductService { private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService(); /// /// 基于当前库内卖价(非必须已发布)模拟计费,用于运营核对附加服务/偏远/申报比例等。 /// [HttpPost("/channel/sales-product/billing/simulate")] public async Task SimulateBillingAsync( [FromBody] SalesProductBillingSimulateInput input) { if (input.Length <= 0 || input.Width <= 0 || input.Height <= 0 || input.ActualWeight <= 0) { throw new BusinessException("长宽高与实重必须大于0"); } var product = await GetProductOrThrowAsync(input.SalesProductId); var countryCode = input.CountryCode?.Trim().ToUpperInvariant() ?? throw new BusinessException("目的国家不能为空"); if (string.IsNullOrWhiteSpace(countryCode)) { throw new BusinessException("目的国家不能为空"); } var warnings = new List(); var priceSnapshot = await BuildPricePublishSnapshotAsync(product.Id); var rulesSnapshot = await BuildRulesPublishSnapshotAsync(product.Id); var weight = new OrderWeight { Type = OrderWeightTypeCodes.Forecast, Length = input.Length, Width = input.Width, Height = input.Height, ActualWeight = input.ActualWeight, Cubic = ParcelBillingCalculator.CalculateCubic(input.Length, input.Width, input.Height) }; decimal chargeableWeight; try { chargeableWeight = await ResolveSimulateChargeableWeightAsync(product, rulesSnapshot, weight); } catch (BusinessException) { throw; } catch (InvalidOperationException ex) { throw new BusinessException($"计费重计算失败:{ex.Message}"); } var zoneKeys = ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot( priceSnapshot, countryCode, input.Province, input.City, input.ZipCode); if (zoneKeys == null || zoneKeys.Count == 0) { throw new BusinessException("价格分区未匹配"); } var isRemote = input.IsRemote ?? await IsSimulateRemoteHitAsync(product, rulesSnapshot, countryCode, input.Province, input.City, input.ZipCode); var additionalServices = (input.AdditionalServices ?? []) .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var allowed = OmsDocHelper.DeserializeJson>(product.AdditionalServices) ?? []; var allowedSet = allowed .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); var invalidServices = additionalServices.Where(x => !allowedSet.Contains(x)).ToList(); if (invalidServices.Count > 0) { warnings.Add($"附加服务不在产品可选范围:{string.Join(", ", invalidServices)}"); additionalServices = additionalServices.Where(allowedSet.Contains).ToList(); } var cargoAttribute = string.IsNullOrWhiteSpace(input.CargoAttribute) ? CargoAttributeCodes.General : input.CargoAttribute.Trim(); var warehouseName = await ResolveSimulateWarehouseNameAsync(input.WarehouseId); var priceTierKey = string.IsNullOrWhiteSpace(input.PriceTierKey) ? null : input.PriceTierKey.Trim(); var priceVersion = DateTime.Now.ToString("yyyyMMddHHmmss"); var priceEffectiveTime = DateTime.Now; var conditionContext = OrderRuleEvaluationContext.FromOrderOnly(new Order { ProductCode = product.ProductCode, ConsigneeCountry = countryCode, CargoAttribute = cargoAttribute, WarehouseId = input.WarehouseId, DeclaredSum = input.DeclaredSum, DeclaredCurrency = input.DeclaredCurrency, IsRemote = isRemote ? (byte)1 : (byte)0, AdditionalServices = additionalServices.Count == 0 ? null : OmsDocHelper.SerializeJson(additionalServices) }); var remoteAreaId = product.RemoteAreaId is > 0 ? product.RemoteAreaId : rulesSnapshot.RemoteAreaId; var remoteZoneKeys = isRemote ? ParcelBillingCalculator.BuildRemoteZoneKeys(remoteAreaId, rulesSnapshot.RemoteAreaName) : Array.Empty(); var receivableLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines( priceSnapshot, priceVersion, priceEffectiveTime, chargeableWeight, zoneKeys, cargoAttribute, priceTierKey, input.WarehouseId, warehouseName, conditionContext, input.DeclaredSum, input.DeclaredCurrency, remoteZoneKeys); if (receivableLines.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(priceSnapshot)) { var detail = ParcelBillingCalculator.ExplainQuotePriceMiss( priceSnapshot, chargeableWeight, zoneKeys, cargoAttribute, priceTierKey, input.WarehouseId, warehouseName); warnings.Add($"卖价未匹配到有效价格:{detail}"); } var receivableFees = receivableLines.Select(x => new SalesProductBillingSimulateFeeOutput { CostItem = x.Fee.CostItem, Amount = x.Fee.Amount, Currency = x.Fee.Currency ?? string.Empty, Formula = x.FormulaPart }).ToList(); var payableFees = new List(); try { payableFees = await TrySimulatePayableFeesAsync( product, rulesSnapshot, zoneKeys, chargeableWeight, cargoAttribute, input.WarehouseId, warehouseName, isRemote, remoteZoneKeys, input.DeclaredSum, input.DeclaredCurrency, priceVersion, priceEffectiveTime, warnings); } catch (Exception ex) { warnings.Add($"应付估算失败:{ex.Message}"); } var receivableTotal = SumByCurrency(receivableFees); var payableTotal = SumByCurrency(payableFees); decimal? margin = null; if (receivableTotal.Count == 1 && payableTotal.Count == 1 && string.Equals(receivableTotal.Keys.First(), payableTotal.Keys.First(), StringComparison.OrdinalIgnoreCase)) { margin = receivableTotal.Values.First() - payableTotal.Values.First(); } else if (receivableTotal.Count > 1 || payableTotal.Count > 1) { warnings.Add("多币种费用无法直接计算毛利"); } else if (receivableTotal.Count == 1 && payableTotal.Count == 0) { warnings.Add("仅返回应收估算,无应付费用"); } return new SalesProductBillingSimulateOutput { ChargeableWeight = chargeableWeight, ZoneKeys = zoneKeys.ToList(), IsRemote = isRemote, ReceivableFees = receivableFees, PayableFees = payableFees, ReceivableTotal = receivableTotal, PayableTotal = payableTotal, Margin = margin, Warnings = warnings }; } private async Task ResolveSimulateChargeableWeightAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto rulesSnapshot, OrderWeight weight) { var weightRuleId = product.WeightRuleId is > 0 ? product.WeightRuleId : rulesSnapshot.WeightRuleId; if (weightRuleId is not > 0) { throw new BusinessException("未配置计重规则"); } IReadOnlyList ruleInfos; if (rulesSnapshot.WeightRuleInfos is { Count: > 0 }) { ruleInfos = RulesSnapshotHelper.ToWeightRuleEntities(rulesSnapshot.WeightRuleInfos); } else { ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value); } var (_, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(weight, ruleInfos); var weightRoundingId = product.WeightRoundingId is > 0 ? product.WeightRoundingId : rulesSnapshot.WeightRoundingId; if (weightRoundingId is not > 0) { throw new BusinessException("未配置计重进位"); } IReadOnlyList ranges; if (rulesSnapshot.WeightRoundingInfos is { Count: > 0 }) { ranges = RulesSnapshotHelper.ToWeightRoundingEntities(rulesSnapshot.WeightRoundingInfos); } else { ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value); } return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); } private async Task IsSimulateRemoteHitAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto rulesSnapshot, string countryCode, string? province, string? city, string? zipCode) { var areaId = product.RemoteAreaId is > 0 ? product.RemoteAreaId : rulesSnapshot.RemoteAreaId; if (areaId is not > 0) { return false; } if (rulesSnapshot.RemoteAreaInfos is { Count: > 0 }) { return RulesSnapshotHelper.IsAreaHitFromSnapshot( rulesSnapshot.RemoteAreaInfos, countryCode, province, city, zipCode); } var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( rule, countryCode, province, city, zipCode)); } private async Task ResolveSimulateWarehouseNameAsync(int? warehouseId) { if (warehouseId is not > 0) { return null; } var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == warehouseId.Value); return warehouse?.WarehouseName; } private async Task> TrySimulatePayableFeesAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto rulesSnapshot, IReadOnlyList zoneKeys, decimal chargeableWeight, string cargoAttribute, int? warehouseId, string? warehouseName, bool isRemote, IReadOnlyList remoteZoneKeys, decimal? declaredSum, string? declaredCurrency, string priceVersion, DateTime priceEffectiveTime, List warnings) { var providers = rulesSnapshot.Providers.Count > 0 ? rulesSnapshot.Providers : (await GetAllocationCoreAsync(product.Id)).Providers .Select(PublishSnapshotSlimmer.SlimAllocationProvider) .ToList(); var filtered = AllocationResolver.FilterProvidersDetailed( providers, customerId: null, OrderRuleEvaluationContext.FromOrderOnly(new Order { ProductCode = product.ProductCode, CargoAttribute = cargoAttribute, WarehouseId = warehouseId, DeclaredSum = declaredSum, DeclaredCurrency = declaredCurrency, IsRemote = isRemote ? (byte)1 : (byte)0 })).Passed; var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0); if (provider?.ServiceChannelId is not > 0) { warnings.Add("未配置可用排货服务渠道,跳过应付估算"); return []; } var channelPrices = await BuildChannelPriceOutputsAsync(provider.ServiceChannelId.Value); if (channelPrices.Count == 0) { warnings.Add($"服务渠道 {provider.ServiceChannelId} 无买价,跳过应付估算"); return []; } var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == provider.ServiceChannelId.Value); var payableRemoteZoneKeys = isRemote ? ParcelBillingCalculator.BuildRemoteZoneKeys( channel?.RemoteAreaId is > 0 ? channel.RemoteAreaId : null) : Array.Empty(); // 渠道未配偏远分区时回落产品偏远键,便于模拟偏远费行 if (isRemote && payableRemoteZoneKeys.Count == 0) { payableRemoteZoneKeys = remoteZoneKeys; } var payableContext = OrderRuleEvaluationContext.FromOrderOnly(new Order { ProductCode = product.ProductCode, ConsigneeCountry = null, CargoAttribute = cargoAttribute, WarehouseId = warehouseId, DeclaredSum = declaredSum, DeclaredCurrency = declaredCurrency, IsRemote = isRemote ? (byte)1 : (byte)0 }); var payable = ParcelBillingCalculator.CalculatePayableFees( channelPrices, priceVersion, priceEffectiveTime, chargeableWeight, zoneKeys, cargoAttribute, OrderFeeChargingStageCodes.Forecast, warehouseId, warehouseName, payableContext, declaredSum, declaredCurrency, payableRemoteZoneKeys); if (payable.Count == 0) { warnings.Add($"服务渠道 {provider.ServiceChannelId} 买价未匹配,跳过应付估算"); return []; } warnings.Add($"应付按排货服务渠道 {provider.ServiceChannelId} 估算(首个可用渠道)"); return payable.Select(x => new SalesProductBillingSimulateFeeOutput { CostItem = x.CostItem, Amount = x.Amount, Currency = x.Currency ?? string.Empty }).ToList(); } private async Task> BuildChannelPriceOutputsAsync(int serviceChannelId) { var prices = await ServiceChannelRepository.GetPricesAsync(serviceChannelId); var priceIds = prices.Select(x => x.Id).ToArray(); var factorMap = (await ServiceChannelRepository.GetPriceFactorsAsync(priceIds)) .GroupBy(x => x.PriceId) .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList()); var detailMap = (await ServiceChannelRepository.GetPriceDetailsAsync(priceIds)) .GroupBy(x => x.PriceId) .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()); return prices.Select(price => new ServiceChannelPriceListOutput { Id = price.Id, ServiceChannelId = price.ServiceChannelId, SerialNumber = price.SerialNumber, Status = (byte)price.Status, PriceName = price.PriceName, CostItem = price.CostItem, CostUnit = price.CostUnit, Currency = price.Currency, ChargingCondition = price.ChargingCondition, Factors = factorMap.TryGetValue(price.Id, out var factors) ? factors.Select(f => new ChannelPriceFactorOutput { Id = f.Id, PriceId = f.PriceId, SerialNumber = f.SerialNumber, Factor = f.Factor, FactorValues = OmsDocHelper.DeserializeJson>(f.FactorValues) ?? [] }).ToList() : [], Details = detailMap.TryGetValue(price.Id, out var details) ? details.Select(d => new ChannelPriceDetailOutput { Id = d.Id, PriceId = d.PriceId, FactorValuesJson = OmsDocHelper.DeserializeJsonObject(d.FactorValuesJson), CalculationFormula = d.CalculationFormula, CalculatedValue = d.CalculatedValue, CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(d.CustomerGroupValues) }).ToList() : [] }).ToList(); } private static Dictionary SumByCurrency(IEnumerable fees) { return fees .GroupBy(x => string.IsNullOrWhiteSpace(x.Currency) ? "UNK" : x.Currency.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.Sum(x => x.Amount), StringComparer.OrdinalIgnoreCase); } }