feat: 揽收承运推送件重尺并按卖价买价出账
All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-29 19:19:24 +08:00
parent 24b8c29c9b
commit 5fb6888b55
9 changed files with 787 additions and 4 deletions

9
db.sql
View File

@ -1907,6 +1907,15 @@ CREATE TABLE `t_pickup_order` (
`ParcelCount` INT NOT NULL DEFAULT 0 COMMENT '关联票数',
`TotalActualWeight` DECIMAL(18,3) NULL COMMENT '汇总实重',
`ChargeableWeight` DECIMAL(18,3) NULL COMMENT '计费重',
`VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重',
`PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON',
`CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)',
`CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)',
`CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)',
`CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文',
`ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON',
`PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON',
`FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间',
`Currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '币种',
`FreightAmount` DECIMAL(18,2) NULL COMMENT '对客揽收运费',
`OtherFeeAmount` DECIMAL(18,2) NULL COMMENT '对客其他费',

View File

@ -0,0 +1,11 @@
-- 揽收单:承运推送件重尺 + 系统卖价/买价费用快照
ALTER TABLE `t_pickup_order`
ADD COLUMN `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重' AFTER `ChargeableWeight`,
ADD COLUMN `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON' AFTER `VolumetricWeight`,
ADD COLUMN `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)' AFTER `PackageSizesJson`,
ADD COLUMN `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)' AFTER `CarrierFreightWeight`,
ADD COLUMN `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)' AFTER `CarrierQuotedFreightAmount`,
ADD COLUMN `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文' AFTER `CarrierQuotedReceivableAmount`,
ADD COLUMN `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON' AFTER `CarrierQuoteJson`,
ADD COLUMN `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON' AFTER `ReceivableFeeJson`,
ADD COLUMN `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间' AFTER `PayableFeeJson`;

View File

@ -273,6 +273,24 @@ public class PickupOrderOutput
public decimal? ChargeableWeight { get; set; }
public decimal? VolumetricWeight { get; set; }
public List<PickupPackageSizeOutput> PackageSizes { get; set; } = [];
public decimal? CarrierFreightWeight { get; set; }
public decimal? CarrierQuotedFreightAmount { get; set; }
public decimal? CarrierQuotedReceivableAmount { get; set; }
public PickupCarrierQuoteOutput? CarrierQuote { get; set; }
public List<PickupFeeLineOutput> ReceivableFees { get; set; } = [];
public List<PickupFeeLineOutput> PayableFees { get; set; } = [];
public DateTime? FeePushedAt { get; set; }
public string Currency { get; set; } = "CNY";
public decimal? FreightAmount { get; set; }
@ -303,3 +321,88 @@ public class PickupOrderOutput
public List<PickupOrderRejectItem> Rejected { get; set; } = [];
}
public class PickupPackageSizeOutput
{
public decimal Length { get; set; }
public decimal Width { get; set; }
public decimal Height { get; set; }
public int Count { get; set; }
}
public class PickupFeeLineOutput
{
public string CostItem { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Currency { get; set; } = string.Empty;
}
public class PickupCarrierQuoteOutput
{
public string? ServiceMode { get; set; }
public string? PayMode { get; set; }
public List<PickupCarrierValueFeeOutput> ValueInfoList { get; set; } = [];
}
public class PickupCarrierValueFeeOutput
{
public string? FeeCode { get; set; }
public string? FeeName { get; set; }
public decimal? FeeAmount { get; set; }
}
public class PickupOpenApiEnvelope
{
public string Code { get; set; } = "200";
public string Msg { get; set; } = "成功";
public PickupOpenApiPushFeeResult? Data { get; set; }
}
public class PickupOpenApiPushFeeResult
{
public string PickupNo { get; set; } = string.Empty;
public decimal ActualWeight { get; set; }
public decimal ChargeableWeight { get; set; }
public decimal? VolumetricWeight { get; set; }
public decimal ReceivableAmount { get; set; }
public decimal PayableAmount { get; set; }
public decimal? CarrierQuotedFreightAmount { get; set; }
}
public class PickupOpenApiPushFeeInput
{
public string? WaybillNumber { get; set; }
public decimal ActualWeight { get; set; }
public decimal? FreightWeight { get; set; }
public decimal? TotalFreightAmount { get; set; }
public decimal? ReceivableAmount { get; set; }
public List<PickupCarrierValueFeeOutput>? ValueInfoList { get; set; }
public List<PickupPackageSizeOutput>? GoodsSizeList { get; set; }
public string? ServiceMode { get; set; }
public string? PayMode { get; set; }
}

View File

@ -0,0 +1,76 @@
using Fake.Application;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 国内揽收开放接口(承运商推送件重尺)。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Order")]
public class PickupOpenApiService : ApplicationService
{
private PickupOrderFeePushService FeePush =>
LazyServiceProvider.GetRequiredService<PickupOrderFeePushService>();
/// <summary>
/// 开放平台路由:method=open.api.openCommon.pushFee,仅校验 appKey。
/// </summary>
[AllowAnonymous]
[HttpPost("/openapi")]
public Task<PickupOpenApiEnvelope> InvokeAsync(
[FromQuery] string? appKey,
[FromQuery] string? method,
[FromBody] PickupOpenApiPushFeeInput? body) =>
HandlePushFeeAsync(appKey, method, body);
/// <summary>
/// 推送揽收重量/尺寸并按当前产品计算应收应付。
/// </summary>
[AllowAnonymous]
[HttpPost("/openapi/openCommon/pushFee")]
public Task<PickupOpenApiEnvelope> PushFeeAsync(
[FromQuery] string? appKey,
[FromBody] PickupOpenApiPushFeeInput? body) =>
HandlePushFeeAsync(appKey, PickupOrderFeePushService.PushFeeMethod, body);
private async Task<PickupOpenApiEnvelope> HandlePushFeeAsync(
string? appKey,
string? method,
PickupOpenApiPushFeeInput? body)
{
try
{
if (!string.IsNullOrWhiteSpace(method)
&& !string.Equals(method.Trim(), PickupOrderFeePushService.PushFeeMethod, StringComparison.OrdinalIgnoreCase))
{
return Fail("400", $"不支持的 method:{method}");
}
var data = await FeePush.PushFeeAsync(appKey, body);
return new PickupOpenApiEnvelope
{
Code = "200",
Msg = "成功",
Data = data
};
}
catch (BusinessException ex)
{
return Fail("400", ex.Message);
}
catch (Exception ex)
{
return Fail("500", string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message);
}
}
private static PickupOpenApiEnvelope Fail(string code, string msg) =>
new()
{
Code = code,
Msg = msg,
Data = null
};
}

View File

@ -0,0 +1,515 @@
using Fake.Application;
using Fake.DependencyInjection;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.Itfx;
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.Consts;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 承运推送揽收件重尺后,按当前销售产品卖价/买价出账。
/// </summary>
public class PickupOrderFeePushService : IScopedDependency
{
public const string PushFeeMethod = "open.api.openCommon.pushFee";
private readonly IPickupOrderRepository _pickupOrderRepository;
private readonly ISystemOtherConfigRepository _otherConfigRepository;
private readonly ISalesProductRepository _salesProductRepository;
private readonly IServiceChannelRepository _serviceChannelRepository;
private readonly IServiceChannelGroupRepository _serviceChannelGroupRepository;
private readonly IWeightRuleRepository _weightRuleRepository;
private readonly IWeightRoundingRepository _weightRoundingRepository;
private readonly IKpiAreaRepository _kpiAreaRepository;
private readonly IWarehouseAddressRepository _warehouseAddressRepository;
private readonly IItfxMetadataClient _itfxMetadataClient;
public PickupOrderFeePushService(
IPickupOrderRepository pickupOrderRepository,
ISystemOtherConfigRepository otherConfigRepository,
ISalesProductRepository salesProductRepository,
IServiceChannelRepository serviceChannelRepository,
IServiceChannelGroupRepository serviceChannelGroupRepository,
IWeightRuleRepository weightRuleRepository,
IWeightRoundingRepository weightRoundingRepository,
IKpiAreaRepository kpiAreaRepository,
IWarehouseAddressRepository warehouseAddressRepository,
IItfxMetadataClient itfxMetadataClient)
{
_pickupOrderRepository = pickupOrderRepository;
_otherConfigRepository = otherConfigRepository;
_salesProductRepository = salesProductRepository;
_serviceChannelRepository = serviceChannelRepository;
_serviceChannelGroupRepository = serviceChannelGroupRepository;
_weightRuleRepository = weightRuleRepository;
_weightRoundingRepository = weightRoundingRepository;
_kpiAreaRepository = kpiAreaRepository;
_warehouseAddressRepository = warehouseAddressRepository;
_itfxMetadataClient = itfxMetadataClient;
}
public async Task<PickupOpenApiPushFeeResult> PushFeeAsync(string? appKey, PickupOpenApiPushFeeInput? input)
{
var key = (appKey ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(key))
{
throw new BusinessException("appKey 不能为空");
}
if (input == null)
{
throw new BusinessException("请求体不能为空");
}
var pickupNo = (input.WaybillNumber ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(pickupNo))
{
throw new BusinessException("waybillNumber 不能为空");
}
if (input.ActualWeight <= 0)
{
throw new BusinessException("actualWeight 必须大于 0");
}
var sizes = NormalizeSizes(input.GoodsSizeList);
if (sizes.Count == 0)
{
throw new BusinessException("goodsSizeList 不能为空");
}
var pickup = await _pickupOrderRepository.GetByPickupNoAsync(pickupNo)
?? throw new BusinessException("揽收单不存在");
await EnsureAppKeyAsync(key, pickup);
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.ActualWeight,
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.ActualWeight;
pickup.VolumetricWeight = volumetricWeight;
pickup.ChargeableWeight = chargeableWeight;
pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes);
pickup.CarrierFreightWeight = input.FreightWeight;
pickup.CarrierQuotedFreightAmount = input.TotalFreightAmount;
pickup.CarrierQuotedReceivableAmount = input.ReceivableAmount;
pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput
{
ServiceMode = input.ServiceMode,
PayMode = input.PayMode,
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,
ActualWeight = input.ActualWeight,
ChargeableWeight = chargeableWeight,
VolumetricWeight = volumetricWeight,
ReceivableAmount = receivableTotal,
PayableAmount = payableTotal,
CarrierQuotedFreightAmount = input.TotalFreightAmount
};
}
private async Task EnsureAppKeyAsync(string appKey, PickupOrder pickup)
{
var config = await _otherConfigRepository.GetByConfigCodeAsync(SystemOtherConfigCodes.PickupOpenApi);
if (config != null && config.Enabled && !string.IsNullOrWhiteSpace(config.AppKey))
{
if (!string.Equals(config.AppKey.Trim(), appKey, StringComparison.Ordinal))
{
throw new BusinessException("appKey 不正确");
}
return;
}
var expected = await ResolvePickupAppKeyAsync(pickup);
if (string.IsNullOrWhiteSpace(expected)
|| !string.Equals(expected, appKey, StringComparison.Ordinal))
{
throw new BusinessException("appKey 不正确");
}
}
private async Task<string?> ResolvePickupAppKeyAsync(PickupOrder pickup)
{
if (string.IsNullOrWhiteSpace(pickup.CarrierService))
{
return null;
}
var product = await _salesProductRepository.GetByProductCodeAsync(pickup.CarrierService.Trim());
if (product == null)
{
return null;
}
var providers = await _salesProductRepository.GetAllocationProvidersAsync(product.Id);
var channelId = providers
.Where(x => x.ServiceChannelId is > 0)
.OrderBy(x => x.SerialNumber)
.Select(x => x.ServiceChannelId!.Value)
.FirstOrDefault();
if (channelId <= 0)
{
return null;
}
var channel = await _serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId);
if (channel?.GroupId is not > 0)
{
return null;
}
var group = await _serviceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == channel.GroupId.Value);
if (group == null || group.AppId <= 0)
{
return null;
}
var app = await _itfxMetadataClient.GetAppAsync(group.AppId);
return app?.AppKey?.Trim();
}
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.ApplyPriceAreaToSnapshotAsync(snapshot, product, _kpiAreaRepository);
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
};
}

View File

@ -1156,6 +1156,15 @@ public class PickupOrderService : ApplicationService
ParcelCount = entity.ParcelCount,
TotalActualWeight = entity.TotalActualWeight,
ChargeableWeight = entity.ChargeableWeight,
VolumetricWeight = entity.VolumetricWeight,
PackageSizes = OmsDocHelper.DeserializeJson<List<PickupPackageSizeOutput>>(entity.PackageSizesJson) ?? [],
CarrierFreightWeight = entity.CarrierFreightWeight,
CarrierQuotedFreightAmount = entity.CarrierQuotedFreightAmount,
CarrierQuotedReceivableAmount = entity.CarrierQuotedReceivableAmount,
CarrierQuote = OmsDocHelper.DeserializeJson<PickupCarrierQuoteOutput>(entity.CarrierQuoteJson),
ReceivableFees = OmsDocHelper.DeserializeJson<List<PickupFeeLineOutput>>(entity.ReceivableFeeJson) ?? [],
PayableFees = OmsDocHelper.DeserializeJson<List<PickupFeeLineOutput>>(entity.PayableFeeJson) ?? [],
FeePushedAt = entity.FeePushedAt,
Currency = entity.Currency,
FreightAmount = entity.FreightAmount,
OtherFeeAmount = entity.OtherFeeAmount,

View File

@ -188,6 +188,13 @@ public class SystemOtherConfigService : ApplicationService
throw new BusinessException("API 中心应用 AppSecret 不能为空");
}
}
else if (string.Equals(configCode, SystemOtherConfigCodes.PickupOpenApi, StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(input.AppKey))
{
throw new BusinessException("揽收开放接口 AppKey 不能为空");
}
}
else if (string.Equals(configCode, SystemOtherConfigCodes.WeChatMp, StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(input.AppKey))

View File

@ -105,6 +105,54 @@ public class PickupOrder : OmsAuditedAggregateRoot
[SugarColumn(DecimalDigits = 3, IsNullable = true)]
public decimal? ChargeableWeight { get; set; }
[SugarColumn(DecimalDigits = 3, IsNullable = true)]
public decimal? VolumetricWeight { get; set; }
/// <summary>
/// 承运回传尺寸 JSON(goodsSizeList)。
/// </summary>
[SugarColumn(ColumnDataType = "text", IsNullable = true)]
public string? PackageSizesJson { get; set; }
/// <summary>
/// 承运计费重(仅对照,不算账)。
/// </summary>
[SugarColumn(DecimalDigits = 3, IsNullable = true)]
public decimal? CarrierFreightWeight { get; set; }
/// <summary>
/// 承运报价运费合计(仅对照)。
/// </summary>
[SugarColumn(DecimalDigits = 2, IsNullable = true)]
public decimal? CarrierQuotedFreightAmount { get; set; }
/// <summary>
/// 承运报文应收(仅对照,不是客户应收)。
/// </summary>
[SugarColumn(DecimalDigits = 2, IsNullable = true)]
public decimal? CarrierQuotedReceivableAmount { get; set; }
/// <summary>
/// 承运报价原文(附加费、服务方式等)。
/// </summary>
[SugarColumn(ColumnDataType = "text", IsNullable = true)]
public string? CarrierQuoteJson { get; set; }
/// <summary>
/// 系统卖价费用明细 JSON。
/// </summary>
[SugarColumn(ColumnDataType = "text", IsNullable = true)]
public string? ReceivableFeeJson { get; set; }
/// <summary>
/// 系统买价费用明细 JSON。
/// </summary>
[SugarColumn(ColumnDataType = "text", IsNullable = true)]
public string? PayableFeeJson { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? FeePushedAt { get; set; }
[SugarColumn(Length = 8)]
public string Currency { get; set; } = "CNY";

View File

@ -54,4 +54,9 @@ public static class SystemOtherConfigCodes
/// API 中心(接口通 / itfx)网关:BaseUrl=itfx 根地址,AppKey/AppSecret=默认应用凭证,TimeoutSeconds=超时。
/// </summary>
public const string ApiCenter = "api_center";
/// <summary>
/// 国内揽收服务商推送件重尺/费用:仅校验 AppKey(暂不验签)。
/// </summary>
public const string PickupOpenApi = "pickup_open_api";
}