diff --git a/db.sql b/db.sql index a800793..876be41 100644 --- a/db.sql +++ b/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 '对客其他费', diff --git a/sql/migrate_pickup_fee_push.sql b/sql/migrate_pickup_fee_push.sql new file mode 100644 index 0000000..539f2a0 --- /dev/null +++ b/sql/migrate_pickup_fee_push.sql @@ -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`; diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs index 0706a9f..b046a71 100644 --- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs +++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs @@ -273,6 +273,24 @@ public class PickupOrderOutput public decimal? ChargeableWeight { get; set; } + public decimal? VolumetricWeight { get; set; } + + public List 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 ReceivableFees { get; set; } = []; + + public List 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 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 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? ValueInfoList { get; set; } + + public List? GoodsSizeList { get; set; } + + public string? ServiceMode { get; set; } + + public string? PayMode { get; set; } +} diff --git a/src/Oms.Application/Services/Orders/PickupOpenApiService.cs b/src/Oms.Application/Services/Orders/PickupOpenApiService.cs new file mode 100644 index 0000000..a5a2dd4 --- /dev/null +++ b/src/Oms.Application/Services/Orders/PickupOpenApiService.cs @@ -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; + +/// +/// 国内揽收开放接口(承运商推送件重尺)。 +/// +[ApiExplorerSettings(GroupName = "OMS-Order")] +public class PickupOpenApiService : ApplicationService +{ + private PickupOrderFeePushService FeePush => + LazyServiceProvider.GetRequiredService(); + + /// + /// 开放平台路由:method=open.api.openCommon.pushFee,仅校验 appKey。 + /// + [AllowAnonymous] + [HttpPost("/openapi")] + public Task InvokeAsync( + [FromQuery] string? appKey, + [FromQuery] string? method, + [FromBody] PickupOpenApiPushFeeInput? body) => + HandlePushFeeAsync(appKey, method, body); + + /// + /// 推送揽收重量/尺寸并按当前产品计算应收应付。 + /// + [AllowAnonymous] + [HttpPost("/openapi/openCommon/pushFee")] + public Task PushFeeAsync( + [FromQuery] string? appKey, + [FromBody] PickupOpenApiPushFeeInput? body) => + HandlePushFeeAsync(appKey, PickupOrderFeePushService.PushFeeMethod, body); + + private async Task 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 + }; +} diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs new file mode 100644 index 0000000..73dbafa --- /dev/null +++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs @@ -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; + +/// +/// 承运推送揽收件重尺后,按当前销售产品卖价/买价出账。 +/// +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 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 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 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> 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>(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> CalculatePayableAsync( + SalesProduct product, + PickupOrder pickup, + IReadOnlyList 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>(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> 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>(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 NormalizeSizes(List? 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 + }; +} diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs index 9020543..4ee5162 100644 --- a/src/Oms.Application/Services/Orders/PickupOrderService.cs +++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs @@ -1156,6 +1156,15 @@ public class PickupOrderService : ApplicationService ParcelCount = entity.ParcelCount, TotalActualWeight = entity.TotalActualWeight, ChargeableWeight = entity.ChargeableWeight, + VolumetricWeight = entity.VolumetricWeight, + PackageSizes = OmsDocHelper.DeserializeJson>(entity.PackageSizesJson) ?? [], + CarrierFreightWeight = entity.CarrierFreightWeight, + CarrierQuotedFreightAmount = entity.CarrierQuotedFreightAmount, + CarrierQuotedReceivableAmount = entity.CarrierQuotedReceivableAmount, + CarrierQuote = OmsDocHelper.DeserializeJson(entity.CarrierQuoteJson), + ReceivableFees = OmsDocHelper.DeserializeJson>(entity.ReceivableFeeJson) ?? [], + PayableFees = OmsDocHelper.DeserializeJson>(entity.PayableFeeJson) ?? [], + FeePushedAt = entity.FeePushedAt, Currency = entity.Currency, FreightAmount = entity.FreightAmount, OtherFeeAmount = entity.OtherFeeAmount, diff --git a/src/Oms.Application/Services/System/SystemOtherConfigService.cs b/src/Oms.Application/Services/System/SystemOtherConfigService.cs index 2747909..c25a817 100644 --- a/src/Oms.Application/Services/System/SystemOtherConfigService.cs +++ b/src/Oms.Application/Services/System/SystemOtherConfigService.cs @@ -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)) diff --git a/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs b/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs index b8de6ce..96acab6 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs @@ -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; } + + /// + /// 承运回传尺寸 JSON(goodsSizeList)。 + /// + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string? PackageSizesJson { get; set; } + + /// + /// 承运计费重(仅对照,不算账)。 + /// + [SugarColumn(DecimalDigits = 3, IsNullable = true)] + public decimal? CarrierFreightWeight { get; set; } + + /// + /// 承运报价运费合计(仅对照)。 + /// + [SugarColumn(DecimalDigits = 2, IsNullable = true)] + public decimal? CarrierQuotedFreightAmount { get; set; } + + /// + /// 承运报文应收(仅对照,不是客户应收)。 + /// + [SugarColumn(DecimalDigits = 2, IsNullable = true)] + public decimal? CarrierQuotedReceivableAmount { get; set; } + + /// + /// 承运报价原文(附加费、服务方式等)。 + /// + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string? CarrierQuoteJson { get; set; } + + /// + /// 系统卖价费用明细 JSON。 + /// + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string? ReceivableFeeJson { get; set; } + + /// + /// 系统买价费用明细 JSON。 + /// + [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"; diff --git a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs index b11706e..81ad7c1 100644 --- a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs +++ b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs @@ -50,8 +50,13 @@ public static class SystemOtherConfigCodes /// public const string LabelDefaultTemplate = "label_default_template"; -/// -/// API 中心(接口通 / itfx)网关:BaseUrl=itfx 根地址,AppKey/AppSecret=默认应用凭证,TimeoutSeconds=超时。 -/// -public const string ApiCenter = "api_center"; + /// + /// API 中心(接口通 / itfx)网关:BaseUrl=itfx 根地址,AppKey/AppSecret=默认应用凭证,TimeoutSeconds=超时。 + /// + public const string ApiCenter = "api_center"; + + /// + /// 国内揽收服务商推送件重尺/费用:仅校验 AppKey(暂不验签)。 + /// + public const string PickupOpenApi = "pickup_open_api"; }