All checks were successful
Build and Deploy / deploy (push) Successful in 1m49s
用 MatchCondition 中文公式筛选可用渠道后再按方案选渠;失败时落完整排货过程(阶段、公式摘要、池汇总),并兼容旧 CustomerIds。 Co-authored-by: Cursor <cursoragent@cursor.com>
460 lines
18 KiB
C#
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);
|
|
}
|
|
}
|