feat: 揽收承运推送件重尺并按卖价买价出账
All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
24b8c29c9b
commit
5fb6888b55
9
db.sql
9
db.sql
@ -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 '对客其他费',
|
||||
|
||||
11
sql/migrate_pickup_fee_push.sql
Normal file
11
sql/migrate_pickup_fee_push.sql
Normal 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`;
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
76
src/Oms.Application/Services/Orders/PickupOpenApiService.cs
Normal file
76
src/Oms.Application/Services/Orders/PickupOpenApiService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
515
src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
Normal file
515
src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user