admin/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
zzh 358738c333
All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s
feat: 揽收单支持改信息/件重尺/重算费用,创建后自动预报下游
创建成功后自动下游预报回写承运单号并升为已预约;详情可改揽收信息与件重尺并按卖买价重算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 16:12:12 +08:00

520 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}");
EnsureCanReceiveMeasure(pickup);
return await ApplyMeasureAndBillAsync(
pickup,
input.TotalActualWeight,
sizes,
input.CarrierFreightWeight,
input.CarrierQuotedFreightAmount,
input.CarrierQuotedReceivableAmount,
input.ValueInfoList);
}
/// <summary>
/// 按已保存(或入参)件重尺,用当前承运服务卖价/买价重算出账。
/// </summary>
public async Task<PickupOpenApiPushFeeResult> RecalcByPickupAsync(
PickupOrder pickup,
decimal? totalActualWeightOverride = null,
IReadOnlyList<PickupPackageSizeOutput>? sizesOverride = null)
{
EnsureCanReceiveMeasure(pickup);
var weight = totalActualWeightOverride ?? pickup.TotalActualWeight ?? 0;
if (weight <= 0)
{
throw new BusinessException("请先填写实重后再重算费用");
}
var sizes = sizesOverride != null && sizesOverride.Count > 0
? NormalizeSizes(sizesOverride.ToList())
: NormalizeSizes(
OmsDocHelper.DeserializeJson<List<PickupPackageSizeOutput>>(pickup.PackageSizesJson));
if (sizes.Count == 0)
{
throw new BusinessException("请先填写件重尺尺寸后再重算费用");
}
return await ApplyMeasureAndBillAsync(pickup, weight, sizes);
}
public async Task<PickupOpenApiPushFeeResult> ApplyMeasureAndBillAsync(
PickupOrder pickup,
decimal totalActualWeight,
IReadOnlyList<PickupPackageSizeOutput> sizes,
decimal? carrierFreightWeight = null,
decimal? carrierQuotedFreightAmount = null,
decimal? carrierQuotedReceivableAmount = null,
List<PickupCarrierValueFeeOutput>? valueInfoList = null)
{
if (totalActualWeight <= 0)
{
throw new BusinessException("totalActualWeight 必须大于 0");
}
var normalizedSizes = sizes is List<PickupPackageSizeOutput> list
? NormalizeSizes(list)
: NormalizeSizes(sizes.ToList());
if (normalizedSizes.Count == 0)
{
throw new BusinessException("packageSizes 不能为空");
}
if (string.IsNullOrWhiteSpace(pickup.CarrierService))
{
throw new BusinessException("揽收单未选择承运服务(销售产品),无法计费");
}
var product = await _salesProductRepository.GetByProductCodeAsync(pickup.CarrierService.Trim())
?? throw new BusinessException($"未找到承运服务对应销售产品「{pickup.CarrierService}」");
var totalVolume = normalizedSizes.Sum(x => x.Length * x.Width * x.Height * x.Count);
var weightRow = new OrderWeight
{
Type = OrderWeightTypeCodes.Receive,
Length = totalVolume,
Width = 1,
Height = 1,
ActualWeight = 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 = totalActualWeight;
pickup.VolumetricWeight = volumetricWeight;
pickup.ChargeableWeight = chargeableWeight;
pickup.PackageSizesJson = OmsDocHelper.SerializeJson(normalizedSizes);
if (carrierFreightWeight.HasValue)
{
pickup.CarrierFreightWeight = carrierFreightWeight;
}
if (carrierQuotedFreightAmount.HasValue)
{
pickup.CarrierQuotedFreightAmount = carrierQuotedFreightAmount;
}
if (carrierQuotedReceivableAmount.HasValue)
{
pickup.CarrierQuotedReceivableAmount = carrierQuotedReceivableAmount;
}
if (valueInfoList != null)
{
pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput
{
ValueInfoList = 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 = totalActualWeight,
ChargeableWeight = chargeableWeight,
VolumetricWeight = volumetricWeight,
ReceivableAmount = receivableTotal,
CarrierPayableAmount = payableTotal,
CarrierQuotedFreightAmount = pickup.CarrierQuotedFreightAmount
};
}
private static void EnsureCanReceiveMeasure(PickupOrder pickup)
{
if (pickup.PayStatus == PickupPayStatusCodes.Paid)
{
throw new BusinessException("揽收单已支付,拒绝修改件重尺与费用");
}
if (PickupOrderStatusCodes.IsTerminal(pickup.Status)
&& pickup.Status != PickupOrderStatusCodes.Inbound)
{
throw new BusinessException("当前揽收单状态不可接收件重尺");
}
}
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
};
}