feat: 揽收单预报下游改从 t_pickup_order 组装 createOrderOne
All checks were successful
Build and Deploy / deploy (push) Successful in 1m43s

新增 /order/pickup-order/downstream-forward,productCode/supplierCode/地址重量商品均取揽收单,不再依赖关联物流单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-22 19:17:09 +08:00
parent 623db4ffdb
commit 26f9d8be7f
2 changed files with 358 additions and 0 deletions

View File

@ -2,7 +2,9 @@ using Fake.Application;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Shared;
using Oms.Application.Integrations.Itfx;
using Oms.Application.Shared; using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Entities;
@ -48,6 +50,21 @@ public class PickupOrderService : ApplicationService
private ICountryRepository CountryRepository => private ICountryRepository CountryRepository =>
LazyServiceProvider.GetRequiredService<ICountryRepository>(); LazyServiceProvider.GetRequiredService<ICountryRepository>();
private ISalesProductRepository SalesProductRepository =>
LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private IServiceChannelRepository ServiceChannelRepository =>
LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
private IServiceChannelGroupRepository ServiceChannelGroupRepository =>
LazyServiceProvider.GetRequiredService<IServiceChannelGroupRepository>();
private IItfxForwardClient ItfxForwardClient =>
LazyServiceProvider.GetRequiredService<IItfxForwardClient>();
private IItfxMetadataClient ItfxMetadataClient =>
LazyServiceProvider.GetRequiredService<IItfxMetadataClient>();
/// <summary> /// <summary>
/// 创建揽收单并挂载物流单(原子:全部校验通过才落库)。 /// 创建揽收单并挂载物流单(原子:全部校验通过才落库)。
/// </summary> /// </summary>
@ -902,6 +919,157 @@ public class PickupOrderService : ApplicationService
await PickupOrderRepository.UpdateAsync(pickup); await PickupOrderRepository.UpdateAsync(pickup);
} }
/// <summary>
/// 揽收单预报下游:createOrderOne 请求体全部从 t_pickup_order 组装(不读关联物流单)。
/// </summary>
[HttpPost("/order/pickup-order/downstream-forward")]
public async Task<OrderDownstreamForwardResult> DownstreamForwardAsync([FromBody] PickupOrderIdInput input)
{
var pickup = await GetOwnedPickupOrThrowAsync(input.Id);
if (string.IsNullOrWhiteSpace(pickup.CarrierService))
{
throw new BusinessException("请先选择承运服务后再预报下游");
}
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == pickup.WarehouseId);
if (warehouse == null)
{
throw new BusinessException("仓库不存在");
}
var (body, _) = await PickupOrderDownstreamCreateOneBodyBuilder.BuildAsync(
pickup,
warehouse,
SalesProductRepository,
ServiceChannelRepository);
if (string.IsNullOrWhiteSpace(body.ProductCode))
{
throw new BusinessException("无法解析下游 productCode,请检查承运服务对应渠道的预报编码");
}
var creds = await ResolvePickupDownstreamCredentialsAsync(pickup);
var forward = await ItfxForwardClient.ForwardCreateOrderOneAsync(
creds.SupplierCode,
creds.AppKey,
creds.AppSecret,
body);
if (forward.Success
&& ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null)
{
var changed = false;
var waybill = !string.IsNullOrWhiteSpace(data.TrackNo)
? data.TrackNo.Trim()
: data.ServiceNo?.Trim();
if (!string.IsNullOrWhiteSpace(waybill)
&& !string.Equals(pickup.CarrierWaybillNo, waybill, StringComparison.Ordinal))
{
pickup.CarrierWaybillNo = waybill;
changed = true;
}
if (string.IsNullOrWhiteSpace(pickup.CarrierCode)
&& !string.IsNullOrWhiteSpace(creds.SupplierCode))
{
pickup.CarrierCode = creds.SupplierCode;
changed = true;
}
if (changed)
{
await PickupOrderRepository.UpdateAsync(pickup);
}
}
return new OrderDownstreamForwardResult
{
Success = forward.Success,
SupplierCode = creds.SupplierCode,
Url = forward.Url,
RequestJson = forward.RequestJson,
StatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
DownstreamServiceNo = pickup.CarrierWaybillNo,
TrackNo = pickup.CarrierWaybillNo,
};
}
/// <summary>
/// 凭证:优先揽收单 CarrierCode 作为 supplierCode;AppKey/Secret 仍按承运服务→渠道分组解析。
/// </summary>
private async Task<OrderDownstreamCredentials> ResolvePickupDownstreamCredentialsAsync(PickupOrder pickup)
{
var salesProductCode = pickup.CarrierService!.Trim();
var product = await SalesProductRepository.GetByProductCodeAsync(salesProductCode);
if (product == null)
{
throw new BusinessException($"未找到承运服务对应销售产品「{salesProductCode}」");
}
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)
{
throw new BusinessException($"销售产品「{salesProductCode}」未配置服务渠道");
}
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId);
if (channel == null)
{
throw new BusinessException("承运服务关联的服务渠道不存在");
}
if (!channel.GroupId.HasValue || channel.GroupId.Value <= 0)
{
throw new BusinessException($"渠道「{channel.ChannelCode}」未关联服务商分组");
}
var group = await ServiceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == channel.GroupId.Value);
if (group == null)
{
throw new BusinessException("服务商分组不存在或已删除");
}
if (group.AppId <= 0 || group.ApiServiceProviderId <= 0)
{
throw new BusinessException($"服务商「{group.Name}」未配置应用或 API 服务商");
}
var appInfo = await ItfxMetadataClient.GetAppAsync(group.AppId);
var appKey = appInfo?.AppKey?.Trim();
var appSecret = appInfo?.AppSecret?.Trim();
if (string.IsNullOrWhiteSpace(appKey) || string.IsNullOrWhiteSpace(appSecret))
{
throw new BusinessException(
"无法获取应用 AppKey/AppSecret,请检查服务商应用配置或「其他配置-API中心服务」");
}
var supplierCode = pickup.CarrierCode?.Trim();
if (string.IsNullOrWhiteSpace(supplierCode))
{
var sp = await ItfxMetadataClient.GetServiceProviderAsync(group.ApiServiceProviderId);
if (sp == null || string.IsNullOrWhiteSpace(sp.Code))
{
throw new BusinessException("API 服务商编码(supplierCode)未配置");
}
supplierCode = sp.Code.Trim();
}
return new OrderDownstreamCredentials
{
SupplierCode = supplierCode,
AppKey = appKey,
AppSecret = appSecret
};
}
private async Task<string> GeneratePickupNoAsync() private async Task<string> GeneratePickupNoAsync()
{ {
for (var i = 0; i < 8; i++) for (var i = 0; i < 8; i++)

View File

@ -0,0 +1,190 @@
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.SystemAggregate.Entities;
namespace Oms.Application.Shared;
/// <summary>
/// 从揽收单表组装 itfx createOrderOne 标准请求体(不读关联物流单)。
/// </summary>
public static class PickupOrderDownstreamCreateOneBodyBuilder
{
public static async Task<(ItfxCreateOrderOneBody Body, string? ForecastProductCode)> BuildAsync(
PickupOrder pickup,
WarehouseAddress warehouse,
ISalesProductRepository salesProductRepository,
IServiceChannelRepository serviceChannelRepository,
CancellationToken cancellationToken = default)
{
var productCode = await ResolveForecastProductCodeAsync(
pickup.CarrierService,
salesProductRepository,
serviceChannelRepository,
cancellationToken);
var isReverse = string.Equals(
PickupDirectionCodes.NormalizeOrDefault(pickup.Direction),
PickupDirectionCodes.Reverse,
StringComparison.OrdinalIgnoreCase);
var customerSide = MapCustomerSide(pickup);
var warehouseSide = MapWarehouseSide(warehouse);
var weight = pickup.TotalActualWeight ?? pickup.ChargeableWeight;
var qty = pickup.ParcelCount > 0 ? pickup.ParcelCount : 1;
var cargoName = string.IsNullOrWhiteSpace(pickup.Remark)
? "上门揽收包裹"
: pickup.Remark.Trim();
return (new ItfxCreateOrderOneBody
{
ClientNo = pickup.PickupNo,
ServiceNo = pickup.PickupNo,
TrackNo = pickup.CarrierWaybillNo,
ProductCode = productCode,
CargoAttribute = CargoAttributeCodes.General,
WarehouseId = pickup.WarehouseId,
CustomerId = pickup.CustomerId,
Length = 1,
Width = 1,
Height = 1,
ActualWeight = weight,
ChargeableWeight = pickup.ChargeableWeight ?? weight,
IsCod = false,
DeclaredCurrency = pickup.Currency,
DeclaredSum = FormatDecimal(pickup.ReceivableAmount ?? pickup.FreightAmount ?? 1),
Quantity = qty.ToString(),
Sender = isReverse ? warehouseSide.Sender : customerSide.Sender,
Receiver = isReverse ? customerSide.Receiver : warehouseSide.Receiver,
Items =
[
new OrderParcelItemInput
{
CnName = cargoName,
EnName = cargoName,
UnitPrice = 1,
UnitWeight = weight,
Quantity = qty,
Amount = 1,
Weight = weight
}
]
}, productCode);
}
/// <summary>
/// createOrderOne.productCode:承运服务(销售产品)对应渠道的 ForecastCode,缺省回退 CarrierService。
/// </summary>
public static async Task<string?> ResolveForecastProductCodeAsync(
string? carrierService,
ISalesProductRepository salesProductRepository,
IServiceChannelRepository serviceChannelRepository,
CancellationToken cancellationToken = default)
{
var salesProductCode = carrierService?.Trim();
if (string.IsNullOrWhiteSpace(salesProductCode))
{
return null;
}
var product = await salesProductRepository.GetByProductCodeAsync(salesProductCode, cancellationToken);
if (product == null)
{
return salesProductCode;
}
var providers = await salesProductRepository.GetAllocationProvidersAsync(product.Id, cancellationToken);
var channelId = providers
.Where(x => x.ServiceChannelId is > 0)
.OrderBy(x => x.SerialNumber)
.Select(x => x.ServiceChannelId!.Value)
.FirstOrDefault();
if (channelId <= 0)
{
return salesProductCode;
}
var channel = await serviceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId, cancellationToken);
var forecastCode = channel?.ForecastCode?.Trim();
return string.IsNullOrWhiteSpace(forecastCode) ? salesProductCode : forecastCode;
}
private static string? FormatDecimal(decimal? value) =>
value.HasValue ? value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
private static (OrderParcelSenderInput Sender, OrderParcelReceiverInput Receiver) MapCustomerSide(PickupOrder pickup)
{
var sender = new OrderParcelSenderInput
{
SenderName = pickup.SenderName,
Company = pickup.SenderCompany,
Phone = pickup.SenderPhone,
TelePhone = pickup.SenderTelePhone,
Email = pickup.SenderEmail,
CountryCode = string.IsNullOrWhiteSpace(pickup.SenderCountryCode)
? "CN"
: pickup.SenderCountryCode.Trim().ToUpperInvariant(),
Province = pickup.SenderProvince,
City = pickup.SenderCity,
ZipCode = pickup.SenderZipCode,
StreetAddress1 = pickup.SenderStreetAddress1,
StreetAddress2 = pickup.SenderStreetAddress2,
StreetAddress3 = pickup.SenderStreetAddress3,
HouseNumber = pickup.SenderHouseNumber
};
var receiver = new OrderParcelReceiverInput
{
RecipientsName = pickup.SenderName,
CompanyName = pickup.SenderCompany,
Phone = pickup.SenderPhone,
TelePhone = pickup.SenderTelePhone,
Email = pickup.SenderEmail,
CountryCode = string.IsNullOrWhiteSpace(pickup.SenderCountryCode)
? "CN"
: pickup.SenderCountryCode.Trim().ToUpperInvariant(),
Province = pickup.SenderProvince,
City = pickup.SenderCity,
ZipCode = string.IsNullOrWhiteSpace(pickup.SenderZipCode) ? "000000" : pickup.SenderZipCode,
StreetAddress1 = pickup.SenderStreetAddress1,
StreetAddress2 = pickup.SenderStreetAddress2,
StreetAddress3 = pickup.SenderStreetAddress3,
StreetNumber = pickup.SenderHouseNumber
};
return (sender, receiver);
}
private static (OrderParcelSenderInput Sender, OrderParcelReceiverInput Receiver) MapWarehouseSide(
WarehouseAddress warehouse)
{
var contact = string.IsNullOrWhiteSpace(warehouse.Recipient)
? warehouse.WarehouseName
: warehouse.Recipient!.Trim();
var street = string.IsNullOrWhiteSpace(warehouse.ShippingAddress)
? warehouse.WarehouseName
: warehouse.ShippingAddress!.Trim();
var sender = new OrderParcelSenderInput
{
SenderName = contact,
Phone = warehouse.ContactPhone,
CountryCode = "CN",
StreetAddress1 = street,
ZipCode = "000000"
};
var receiver = new OrderParcelReceiverInput
{
RecipientsName = contact,
Phone = warehouse.ContactPhone,
CountryCode = "CN",
ZipCode = "000000",
StreetAddress1 = street
};
return (sender, receiver);
}
}