admin/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs
zzh e5c9364434
All checks were successful
Build and Deploy / deploy (push) Successful in 1m49s
feat: 排货规则支持判断条件公式并完善失败溯源
用 MatchCondition 中文公式筛选可用渠道后再按方案选渠;失败时落完整排货过程(阶段、公式摘要、池汇总),并兼容旧 CustomerIds。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 19:43:05 +08:00

460 lines
18 KiB
C#

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<IWarehouseAddressRepository>();
/// <summary>
/// 基于当前库内卖价(非必须已发布)模拟计费,用于运营核对附加服务/偏远/申报比例等。
/// </summary>
[HttpPost("/channel/sales-product/billing/simulate")]
public async Task<SalesProductBillingSimulateOutput> 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<string>();
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<List<string>>(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<string>();
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<SalesProductBillingSimulateFeeOutput>();
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<decimal> 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<WeightRuleInfo> 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<WeightRoundingInfo> 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<bool> 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<string?> 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<List<SalesProductBillingSimulateFeeOutput>> TrySimulatePayableFeesAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto rulesSnapshot,
IReadOnlyList<string> zoneKeys,
decimal chargeableWeight,
string cargoAttribute,
int? warehouseId,
string? warehouseName,
bool isRemote,
IReadOnlyList<string> remoteZoneKeys,
decimal? declaredSum,
string? declaredCurrency,
string priceVersion,
DateTime priceEffectiveTime,
List<string> 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<string>();
// 渠道未配偏远分区时回落产品偏远键,便于模拟偏远费行
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<List<ServiceChannelPriceListOutput>> 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<List<string>>(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<string, decimal> SumByCurrency(IEnumerable<SalesProductBillingSimulateFeeOutput> 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);
}
}