diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs index 3f5aeaa..fc10454 100644 --- a/src/Oms.Application/Services/Orders/PickupOrderService.cs +++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs @@ -2,7 +2,9 @@ using Fake.Application; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Shared; +using Oms.Application.Integrations.Itfx; using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; @@ -48,6 +50,21 @@ public class PickupOrderService : ApplicationService private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); + private ISalesProductRepository SalesProductRepository => + LazyServiceProvider.GetRequiredService(); + + private IServiceChannelRepository ServiceChannelRepository => + LazyServiceProvider.GetRequiredService(); + + private IServiceChannelGroupRepository ServiceChannelGroupRepository => + LazyServiceProvider.GetRequiredService(); + + private IItfxForwardClient ItfxForwardClient => + LazyServiceProvider.GetRequiredService(); + + private IItfxMetadataClient ItfxMetadataClient => + LazyServiceProvider.GetRequiredService(); + /// /// 创建揽收单并挂载物流单(原子:全部校验通过才落库)。 /// @@ -902,6 +919,157 @@ public class PickupOrderService : ApplicationService await PickupOrderRepository.UpdateAsync(pickup); } + /// + /// 揽收单预报下游:createOrderOne 请求体全部从 t_pickup_order 组装(不读关联物流单)。 + /// + [HttpPost("/order/pickup-order/downstream-forward")] + public async Task 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, + }; + } + + /// + /// 凭证:优先揽收单 CarrierCode 作为 supplierCode;AppKey/Secret 仍按承运服务→渠道分组解析。 + /// + private async Task 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 GeneratePickupNoAsync() { for (var i = 0; i < 8; i++) diff --git a/src/Oms.Application/Shared/PickupOrderDownstreamCreateOneBodyBuilder.cs b/src/Oms.Application/Shared/PickupOrderDownstreamCreateOneBodyBuilder.cs new file mode 100644 index 0000000..4562e3e --- /dev/null +++ b/src/Oms.Application/Shared/PickupOrderDownstreamCreateOneBodyBuilder.cs @@ -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; + +/// +/// 从揽收单表组装 itfx createOrderOne 标准请求体(不读关联物流单)。 +/// +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); + } + + /// + /// createOrderOne.productCode:承运服务(销售产品)对应渠道的 ForecastCode,缺省回退 CarrierService。 + /// + public static async Task 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); + } +}