All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
分区明细独立版本化并缓存加载,计费包缺失严格失败;同步态接口不再拉全文。 Co-authored-by: Cursor <cursoragent@cursor.com>
439 lines
18 KiB
C#
439 lines
18 KiB
C#
using Fake.Application;
|
||
using Fake.DependencyInjection;
|
||
using Oms.Application.Dtos.Channels;
|
||
using Oms.Application.Dtos.Orders;
|
||
using Oms.Application.Services.Orders.Issues;
|
||
using Oms.Application.Shared;
|
||
using Oms.Domain.ChannelsAggregate.Entities;
|
||
using Oms.Domain.ChannelsAggregate.Repositories;
|
||
using Oms.Domain.OrdersAggregate.Consts;
|
||
using Oms.Domain.OrdersAggregate.Entities;
|
||
using Oms.Domain.OrdersAggregate.Repositories;
|
||
using Oms.Domain.SystemAggregate.Repositories;
|
||
|
||
namespace Oms.Application.Services.Orders;
|
||
|
||
/// <summary>
|
||
/// 承运推送揽收件重尺后,按当前销售产品卖价/买价出账。
|
||
/// </summary>
|
||
public class PickupOrderFeePushService : IScopedDependency
|
||
{
|
||
private readonly IPickupOrderRepository _pickupOrderRepository;
|
||
private readonly ISalesProductRepository _salesProductRepository;
|
||
private readonly IServiceChannelRepository _serviceChannelRepository;
|
||
private readonly IWeightRuleRepository _weightRuleRepository;
|
||
private readonly IWeightRoundingRepository _weightRoundingRepository;
|
||
private readonly IKpiAreaRepository _kpiAreaRepository;
|
||
private readonly IWarehouseAddressRepository _warehouseAddressRepository;
|
||
private readonly KpiAreaPackService _kpiAreaPackService;
|
||
|
||
public PickupOrderFeePushService(
|
||
IPickupOrderRepository pickupOrderRepository,
|
||
ISalesProductRepository salesProductRepository,
|
||
IServiceChannelRepository serviceChannelRepository,
|
||
IWeightRuleRepository weightRuleRepository,
|
||
IWeightRoundingRepository weightRoundingRepository,
|
||
IKpiAreaRepository kpiAreaRepository,
|
||
IWarehouseAddressRepository warehouseAddressRepository,
|
||
KpiAreaPackService kpiAreaPackService)
|
||
{
|
||
_pickupOrderRepository = pickupOrderRepository;
|
||
_salesProductRepository = salesProductRepository;
|
||
_serviceChannelRepository = serviceChannelRepository;
|
||
_weightRuleRepository = weightRuleRepository;
|
||
_weightRoundingRepository = weightRoundingRepository;
|
||
_kpiAreaRepository = kpiAreaRepository;
|
||
_warehouseAddressRepository = warehouseAddressRepository;
|
||
_kpiAreaPackService = kpiAreaPackService;
|
||
}
|
||
|
||
public async Task<PickupOpenApiPushFeeResult> PushFeeAsync(PickupOpenApiPushFeeInput? input)
|
||
{
|
||
if (input == null)
|
||
{
|
||
throw new BusinessException("请求体不能为空");
|
||
}
|
||
|
||
var carrierWaybillNo = (input.CarrierWaybillNo ?? string.Empty).Trim();
|
||
if (string.IsNullOrWhiteSpace(carrierWaybillNo))
|
||
{
|
||
throw new BusinessException("carrierWaybillNo(承运运单号)不能为空");
|
||
}
|
||
|
||
if (input.TotalActualWeight <= 0)
|
||
{
|
||
throw new BusinessException("totalActualWeight 必须大于 0");
|
||
}
|
||
|
||
var sizes = NormalizeSizes(input.PackageSizes);
|
||
if (sizes.Count == 0)
|
||
{
|
||
throw new BusinessException("packageSizes 不能为空");
|
||
}
|
||
|
||
var pickup = await _pickupOrderRepository.GetByCarrierWaybillNoAsync(carrierWaybillNo)
|
||
?? throw new BusinessException($"揽收单不存在:承运运单号 {carrierWaybillNo}");
|
||
|
||
if (pickup.PayStatus == PickupPayStatusCodes.Paid)
|
||
{
|
||
throw new BusinessException("揽收单已支付,拒绝修改件重尺与费用");
|
||
}
|
||
|
||
if (PickupOrderStatusCodes.IsTerminal(pickup.Status)
|
||
&& pickup.Status != PickupOrderStatusCodes.Inbound)
|
||
{
|
||
throw new BusinessException("当前揽收单状态不可接收件重尺");
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(pickup.CarrierService))
|
||
{
|
||
throw new BusinessException("揽收单未选择承运服务(销售产品),无法计费");
|
||
}
|
||
|
||
var product = await _salesProductRepository.GetByProductCodeAsync(pickup.CarrierService.Trim())
|
||
?? throw new BusinessException($"未找到承运服务对应销售产品「{pickup.CarrierService}」");
|
||
|
||
var totalVolume = sizes.Sum(x => x.Length * x.Width * x.Height * x.Count);
|
||
var weightRow = new OrderWeight
|
||
{
|
||
Type = OrderWeightTypeCodes.Receive,
|
||
Length = totalVolume,
|
||
Width = 1,
|
||
Height = 1,
|
||
ActualWeight = input.TotalActualWeight,
|
||
Cubic = ParcelBillingCalculator.CalculateCubic(totalVolume, 1, 1)
|
||
};
|
||
|
||
decimal volumetricWeight;
|
||
decimal chargeableWeight;
|
||
try
|
||
{
|
||
(volumetricWeight, chargeableWeight) = await CalculateChargeableAsync(product, weightRow);
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
throw new BusinessException($"计费重计算失败:{ex.Message}");
|
||
}
|
||
|
||
var countryCode = string.IsNullOrWhiteSpace(pickup.SenderCountryCode)
|
||
? "CN"
|
||
: pickup.SenderCountryCode.Trim().ToUpperInvariant();
|
||
var priceSnapshot = await BuildSellPriceSnapshotAsync(product);
|
||
var zoneKeys = ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
|
||
priceSnapshot,
|
||
countryCode,
|
||
pickup.SenderProvince,
|
||
pickup.SenderCity,
|
||
pickup.SenderZipCode);
|
||
if (zoneKeys == null || zoneKeys.Count == 0)
|
||
{
|
||
throw new BusinessException("卖价分区未匹配,请检查揽收地址与产品价格分区");
|
||
}
|
||
|
||
var warehouse = await _warehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == pickup.WarehouseId);
|
||
var warehouseName = warehouse?.WarehouseName;
|
||
var priceVersion = DateTime.Now.ToString("yyyyMMddHHmmss");
|
||
var priceEffectiveTime = DateTime.Now;
|
||
var cargoAttribute = CargoAttributeCodes.General;
|
||
var conditionContext = OrderRuleEvaluationContext.FromOrderOnly(new Order
|
||
{
|
||
ProductCode = product.ProductCode,
|
||
ConsigneeCountry = countryCode,
|
||
CargoAttribute = cargoAttribute,
|
||
WarehouseId = pickup.WarehouseId,
|
||
CustomerId = pickup.CustomerId
|
||
});
|
||
|
||
var receivableLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
|
||
priceSnapshot,
|
||
priceVersion,
|
||
priceEffectiveTime,
|
||
chargeableWeight,
|
||
zoneKeys,
|
||
cargoAttribute,
|
||
priceTierKey: null,
|
||
pickup.WarehouseId,
|
||
warehouseName,
|
||
conditionContext);
|
||
|
||
if (receivableLines.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(priceSnapshot))
|
||
{
|
||
var detail = ParcelBillingCalculator.ExplainQuotePriceMiss(
|
||
priceSnapshot,
|
||
chargeableWeight,
|
||
zoneKeys,
|
||
cargoAttribute,
|
||
null,
|
||
pickup.WarehouseId,
|
||
warehouseName);
|
||
throw new BusinessException($"卖价未匹配到有效价格:{detail}");
|
||
}
|
||
|
||
var payable = await CalculatePayableAsync(
|
||
product,
|
||
pickup,
|
||
zoneKeys,
|
||
chargeableWeight,
|
||
cargoAttribute,
|
||
warehouseName,
|
||
priceVersion,
|
||
priceEffectiveTime,
|
||
conditionContext);
|
||
|
||
var receivableFees = receivableLines.Select(x => ToFeeLine(x.Fee)).ToList();
|
||
var payableFees = payable.Select(ToFeeLine).ToList();
|
||
var receivableTotal = receivableFees.Sum(x => x.Amount);
|
||
var payableTotal = payableFees.Sum(x => x.Amount);
|
||
var freight = receivableFees.FirstOrDefault()?.Amount ?? receivableTotal;
|
||
var other = Math.Max(0, receivableTotal - freight);
|
||
|
||
pickup.TotalActualWeight = input.TotalActualWeight;
|
||
pickup.VolumetricWeight = volumetricWeight;
|
||
pickup.ChargeableWeight = chargeableWeight;
|
||
pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes);
|
||
pickup.CarrierFreightWeight = input.CarrierFreightWeight;
|
||
pickup.CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount;
|
||
pickup.CarrierQuotedReceivableAmount = input.CarrierQuotedReceivableAmount;
|
||
pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput
|
||
{
|
||
ValueInfoList = input.ValueInfoList ?? []
|
||
});
|
||
pickup.ReceivableFeeJson = OmsDocHelper.SerializeJson(receivableFees);
|
||
pickup.PayableFeeJson = OmsDocHelper.SerializeJson(payableFees);
|
||
pickup.FreightAmount = freight;
|
||
pickup.OtherFeeAmount = other;
|
||
pickup.ReceivableAmount = receivableTotal;
|
||
pickup.CarrierPayableAmount = payableTotal;
|
||
pickup.FeePushedAt = DateTime.Now;
|
||
pickup.UpdateTime = DateTime.Now;
|
||
await _pickupOrderRepository.UpdateAsync(pickup);
|
||
|
||
return new PickupOpenApiPushFeeResult
|
||
{
|
||
PickupNo = pickup.PickupNo,
|
||
TotalActualWeight = input.TotalActualWeight,
|
||
ChargeableWeight = chargeableWeight,
|
||
VolumetricWeight = volumetricWeight,
|
||
ReceivableAmount = receivableTotal,
|
||
CarrierPayableAmount = payableTotal,
|
||
CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount
|
||
};
|
||
}
|
||
|
||
private async Task<(decimal VolumetricWeight, decimal ChargeableWeight)> CalculateChargeableAsync(
|
||
SalesProduct product,
|
||
OrderWeight weight)
|
||
{
|
||
if (product.WeightRuleId is not > 0)
|
||
{
|
||
throw new BusinessException("未配置计重规则");
|
||
}
|
||
|
||
var ruleInfos = await _weightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value);
|
||
var (volumetric, raw) = ParcelBillingCalculator.CalculateRawBillableWeight(weight, ruleInfos);
|
||
if (product.WeightRoundingId is not > 0)
|
||
{
|
||
throw new BusinessException("未配置计重进位");
|
||
}
|
||
|
||
var ranges = await _weightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value);
|
||
var chargeable = ParcelBillingCalculator.ApplyRounding(raw, ranges);
|
||
return (volumetric, chargeable);
|
||
}
|
||
|
||
private async Task<SalesProductPricePublishSnapshotDto> BuildSellPriceSnapshotAsync(SalesProduct product)
|
||
{
|
||
var snapshot = new SalesProductPricePublishSnapshotDto
|
||
{
|
||
SalesProductId = product.Id,
|
||
Prices = (await GetSellPriceOutputsAsync(product.Id)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
|
||
};
|
||
await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync(
|
||
snapshot,
|
||
product,
|
||
_kpiAreaRepository,
|
||
_kpiAreaPackService);
|
||
return snapshot;
|
||
}
|
||
|
||
private async Task<List<SalesProductPriceListOutput>> GetSellPriceOutputsAsync(int salesProductId)
|
||
{
|
||
var prices = await _salesProductRepository.GetPricesAsync(salesProductId);
|
||
var priceIds = prices.Select(x => x.Id).ToArray();
|
||
var factorMap = (await _salesProductRepository.GetPriceFactorsAsync(priceIds))
|
||
.GroupBy(x => x.PriceId)
|
||
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList());
|
||
var detailMap = (await _salesProductRepository.GetPriceDetailsAsync(priceIds))
|
||
.GroupBy(x => x.PriceId)
|
||
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
|
||
|
||
return prices.Select(price => new SalesProductPriceListOutput
|
||
{
|
||
Id = price.Id,
|
||
SalesProductId = price.SalesProductId,
|
||
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 async Task<List<OrderFee>> CalculatePayableAsync(
|
||
SalesProduct product,
|
||
PickupOrder pickup,
|
||
IReadOnlyList<string> zoneKeys,
|
||
decimal chargeableWeight,
|
||
string cargoAttribute,
|
||
string? warehouseName,
|
||
string priceVersion,
|
||
DateTime priceEffectiveTime,
|
||
OrderRuleEvaluationContext conditionContext)
|
||
{
|
||
var providers = await _salesProductRepository.GetAllocationProvidersAsync(product.Id);
|
||
var channelIds = providers.Where(x => x.ServiceChannelId is > 0).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray();
|
||
var channels = channelIds.Length == 0
|
||
? []
|
||
: await _serviceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id));
|
||
var channelNameMap = channels.ToDictionary(x => x.Id, x => x.ChannelName);
|
||
var snapshots = providers.Select(provider => new SalesProductAllocationProviderSnapshotDto
|
||
{
|
||
Id = provider.Id,
|
||
SalesProductId = provider.SalesProductId,
|
||
SerialNumber = provider.SerialNumber,
|
||
ProviderName = provider.ProviderName,
|
||
ServiceChannelId = provider.ServiceChannelId,
|
||
ServiceChannelName = provider.ServiceChannelId is > 0
|
||
&& channelNameMap.TryGetValue(provider.ServiceChannelId.Value, out var name)
|
||
? name
|
||
: null,
|
||
Weight = provider.Weight,
|
||
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? [],
|
||
MatchCondition = provider.MatchCondition
|
||
}).ToList();
|
||
|
||
var filtered = AllocationResolver.FilterProvidersDetailed(snapshots, pickup.CustomerId, conditionContext).Passed;
|
||
var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0);
|
||
if (provider?.ServiceChannelId is not > 0)
|
||
{
|
||
throw new BusinessException("未配置可用排货服务渠道,无法计算应付");
|
||
}
|
||
|
||
var channelPrices = await GetChannelPriceOutputsAsync(provider.ServiceChannelId.Value);
|
||
if (channelPrices.Count == 0)
|
||
{
|
||
throw new BusinessException($"服务渠道 {provider.ServiceChannelId} 无买价");
|
||
}
|
||
|
||
var payable = ParcelBillingCalculator.CalculatePayableFees(
|
||
channelPrices,
|
||
priceVersion,
|
||
priceEffectiveTime,
|
||
chargeableWeight,
|
||
zoneKeys,
|
||
cargoAttribute,
|
||
OrderFeeChargingStageCodes.Forecast,
|
||
pickup.WarehouseId,
|
||
warehouseName,
|
||
conditionContext);
|
||
|
||
if (payable.Count == 0)
|
||
{
|
||
throw new BusinessException($"服务渠道 {provider.ServiceChannelId} 买价未匹配");
|
||
}
|
||
|
||
return payable;
|
||
}
|
||
|
||
private async Task<List<ServiceChannelPriceListOutput>> GetChannelPriceOutputsAsync(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 List<PickupPackageSizeOutput> NormalizeSizes(List<PickupPackageSizeOutput>? list)
|
||
{
|
||
return (list ?? [])
|
||
.Select(x => new PickupPackageSizeOutput
|
||
{
|
||
Length = x.Length,
|
||
Width = x.Width,
|
||
Height = x.Height,
|
||
Count = x.Count <= 0 ? 1 : x.Count
|
||
})
|
||
.Where(x => x.Length > 0 && x.Width > 0 && x.Height > 0)
|
||
.ToList();
|
||
}
|
||
|
||
private static PickupFeeLineOutput ToFeeLine(OrderFee fee) =>
|
||
new()
|
||
{
|
||
CostItem = fee.CostItem,
|
||
Amount = fee.Amount,
|
||
Currency = fee.Currency ?? string.Empty
|
||
};
|
||
}
|