feat: ????????????? A0101/A0301/A0701

Fix UpsertSmallBag ApiCode/TrackNo assembly and MQ-disabled direct fallback; append lifecycle tracks on parcel create, inbound, and outbound. Redact channel fields for customer order list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-22 20:11:25 +08:00
parent 3d5eaa223a
commit 1545bc385f
15 changed files with 536 additions and 80 deletions

View File

@ -4,6 +4,15 @@ public interface ISmallBagTrackOrderClient
{ {
Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default); Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 追加一条生命周期轨迹节点(POST ProviderService/Track/Upsert)。
/// </summary>
Task AppendLifecycleTrackAsync(
string orderNo,
string trackCode,
string? authorization = null,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 同步调用并返回完整请求/响应(仅调试)。 /// 同步调用并返回完整请求/响应(仅调试)。
/// </summary> /// </summary>

View File

@ -6,4 +6,9 @@ namespace Oms.Application.Integrations.SmallBagTrack;
public interface ISmallBagTrackOrderNotifier public interface ISmallBagTrackOrderNotifier
{ {
void Enqueue(SmallBagTrackUpsertRequest request, int orderId, int? customerId = null); void Enqueue(SmallBagTrackUpsertRequest request, int orderId, int? customerId = null);
/// <summary>
/// 异步追加生命周期轨迹节点。
/// </summary>
void EnqueueLifecycleTrackAppend(string orderNo, string trackCode, int orderId, string? authorization = null);
} }

View File

@ -0,0 +1,23 @@
namespace Oms.Application.Integrations.SmallBagTrack;
/// <summary>
/// 小包订单生命周期默认轨迹节点(POST ProviderService/Track/Upsert)。
/// </summary>
internal static class SmallBagTrackLifecycleEvents
{
public const string CreateTrackCode = "A0101";
public const string InboundTrackCode = "A0301";
public const string OutboundTrackCode = "A0701";
public const string TrackPlace = "SZ";
public const float TrackTimeZone = 8f;
/// <summary>轨迹订单类型:小包。</summary>
public const int OrderType = 1;
/// <summary>数据来源:内部系统。</summary>
public const int Source = 2;
}

View File

@ -44,8 +44,24 @@ internal sealed class SmallBagTrackOrderClient(
} }
logger.LogInformation("UpsertSmallBag succeeded for order {OrderNo}", request.OrderNo); logger.LogInformation("UpsertSmallBag succeeded for order {OrderNo}", request.OrderNo);
if (request.AppendInitialTrackOnSuccess)
{
await AppendLifecycleTrackAsync(
request.OrderNo,
SmallBagTrackLifecycleEvents.CreateTrackCode,
request.Authorization,
cancellationToken);
}
} }
public Task AppendLifecycleTrackAsync(
string orderNo,
string trackCode,
string? authorization = null,
CancellationToken cancellationToken = default) =>
AppendLifecycleTrackCoreAsync(orderNo, trackCode, authorization, cancellationToken);
public async Task<SmallBagTrackUpsertResult> UpsertDebugAsync( public async Task<SmallBagTrackUpsertResult> UpsertDebugAsync(
SmallBagTrackUpsertRequest request, SmallBagTrackUpsertRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@ -67,14 +83,120 @@ internal sealed class SmallBagTrackOrderClient(
} }
var appKey = settings.AppKey.Trim(); var appKey = settings.AppKey.Trim();
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var sign = SmallBagTrackSignHelper.GenerateSign(appKey, timestamp, settings.AppSecret.Trim());
var body = SmallBagTrackUpsertApiRequest.FromBusinessRequest(request); var body = SmallBagTrackUpsertApiRequest.FromBusinessRequest(request);
var url = SmallBagTrackUpsertApiRequest.BuildEndpointUrl(settings.BaseUrl); var url = SmallBagTrackUpsertApiRequest.BuildEndpointUrl(settings.BaseUrl);
var authorization = ResolveAuthorization(request); var authorization = ResolveAuthorization(request);
var curlCommand = SmallBagTrackCurlBuilder.Build(url, appKey, timestamp, sign, authorization, body, JsonOptions);
var requestJson = JsonSerializer.Serialize(body, JsonOptions); var requestJson = JsonSerializer.Serialize(body, JsonOptions);
var response = await SendSignedPostAsync(
settings,
appKey,
url,
body,
authorization,
cancellationToken);
var curlCommand = SmallBagTrackCurlBuilder.Build(
url,
appKey,
response.Timestamp,
response.Sign,
authorization,
body,
JsonOptions);
return new SmallBagTrackUpsertResult
{
Skipped = false,
Success = response.Response.IsSuccessStatusCode,
Url = url,
RequestJson = requestJson,
CurlCommand = curlCommand,
StatusCode = (int)response.Response.StatusCode,
ResponseBody = response.ResponseBody
};
}
private async Task AppendLifecycleTrackCoreAsync(
string orderNo,
string trackCode,
string? authorization,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(orderNo))
{
logger.LogWarning("Skip lifecycle track append: orderNo empty");
return;
}
if (string.IsNullOrWhiteSpace(trackCode))
{
logger.LogWarning("Skip lifecycle track append for order {OrderNo}: trackCode empty", orderNo);
return;
}
var settings = await configProvider.GetAsync(cancellationToken);
if (!settings.Enabled
|| string.IsNullOrWhiteSpace(settings.BaseUrl)
|| string.IsNullOrWhiteSpace(settings.AppKey)
|| string.IsNullOrWhiteSpace(settings.AppSecret))
{
return;
}
var appKey = settings.AppKey.Trim();
var trackTime = DateTime.Now;
var body = SmallBagTrackUpsertTrackApiRequest.CreateLifecycleEvent(
appKey,
orderNo.Trim(),
trackCode.Trim(),
trackTime);
var url = SmallBagTrackUpsertTrackApiRequest.BuildEndpointUrl(settings.BaseUrl);
try
{
var response = await SendSignedPostAsync(
settings,
appKey,
url,
body,
authorization,
cancellationToken);
if (response.Response.IsSuccessStatusCode)
{
logger.LogInformation(
"Lifecycle track {TrackCode} appended for order {OrderNo} at {TrackPlace}",
trackCode,
orderNo,
SmallBagTrackLifecycleEvents.TrackPlace);
return;
}
logger.LogWarning(
"Lifecycle track {TrackCode} append failed for order {OrderNo}: {StatusCode} {Body}",
trackCode,
orderNo,
(int)response.Response.StatusCode,
response.ResponseBody);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Lifecycle track {TrackCode} append failed for order {OrderNo}", trackCode, orderNo);
}
}
private async Task<SignedTrackHttpResponse> SendSignedPostAsync<TBody>(
SmallBagTrackOptions settings,
string appKey,
string url,
TBody body,
string? authorization,
CancellationToken cancellationToken)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var sign = SmallBagTrackSignHelper.GenerateSign(appKey, timestamp, settings.AppSecret.Trim());
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url);
httpRequest.Headers.TryAddWithoutValidation("appKey", appKey); httpRequest.Headers.TryAddWithoutValidation("appKey", appKey);
httpRequest.Headers.TryAddWithoutValidation("timestamp", timestamp.ToString()); httpRequest.Headers.TryAddWithoutValidation("timestamp", timestamp.ToString());
@ -87,16 +209,7 @@ internal sealed class SmallBagTrackOrderClient(
using var response = await client.SendAsync(httpRequest, cancellationToken); using var response = await client.SendAsync(httpRequest, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
return new SmallBagTrackUpsertResult return new SignedTrackHttpResponse(response, responseBody, timestamp, sign);
{
Skipped = false,
Success = response.IsSuccessStatusCode,
Url = url,
RequestJson = requestJson,
CurlCommand = curlCommand,
StatusCode = (int)response.StatusCode,
ResponseBody = responseBody
};
} }
private string? ResolveAuthorization(SmallBagTrackUpsertRequest request) private string? ResolveAuthorization(SmallBagTrackUpsertRequest request)
@ -125,4 +238,10 @@ internal sealed class SmallBagTrackOrderClient(
Skipped = true, Skipped = true,
SkipReason = reason SkipReason = reason
}; };
private sealed record SignedTrackHttpResponse(
HttpResponseMessage Response,
string ResponseBody,
long Timestamp,
string Sign);
} }

View File

@ -16,7 +16,11 @@ internal sealed class SmallBagTrackOrderNotifier(
{ {
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
var coordinator = scope.ServiceProvider.GetRequiredService<OrderBatchJobCoordinator>(); var coordinator = scope.ServiceProvider.GetRequiredService<OrderBatchJobCoordinator>();
await coordinator.PublishTrackUpsertAsync(request, orderId, customerId); var published = await coordinator.PublishTrackUpsertAsync(request, orderId, customerId);
if (!published)
{
await FallbackDirectAsync(request, orderId);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -24,12 +28,12 @@ internal sealed class SmallBagTrackOrderNotifier(
ex, ex,
"RabbitMQ track upsert publish failed, fallback to direct call. orderId={OrderId}", "RabbitMQ track upsert publish failed, fallback to direct call. orderId={OrderId}",
orderId); orderId);
await FallbackDirectAsync(request); await FallbackDirectAsync(request, orderId);
} }
}); });
} }
private async Task FallbackDirectAsync(SmallBagTrackUpsertRequest request) private async Task FallbackDirectAsync(SmallBagTrackUpsertRequest request, int orderId)
{ {
try try
{ {
@ -39,7 +43,29 @@ internal sealed class SmallBagTrackOrderNotifier(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Fallback UpsertSmallBag failed for order {OrderNo}", request.OrderNo); logger.LogWarning(ex, "Direct UpsertSmallBag failed for orderId={OrderId} orderNo={OrderNo}", orderId, request.OrderNo);
} }
} }
public void EnqueueLifecycleTrackAppend(string orderNo, string trackCode, int orderId, string? authorization = null)
{
_ = Task.Run(async () =>
{
try
{
using var scope = scopeFactory.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ISmallBagTrackOrderClient>();
await client.AppendLifecycleTrackAsync(orderNo, trackCode, authorization);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Async lifecycle track append failed. orderId={OrderId} orderNo={OrderNo} trackCode={TrackCode}",
orderId,
orderNo,
trackCode);
}
});
}
} }

View File

@ -10,6 +10,7 @@ public static class SmallBagTrackServiceExtensions
services.Configure<SmallBagTrackOptions>(configuration.GetSection(SmallBagTrackOptions.SectionName)); services.Configure<SmallBagTrackOptions>(configuration.GetSection(SmallBagTrackOptions.SectionName));
services.AddHttpClient(SmallBagTrackOrderClient.HttpClientName); services.AddHttpClient(SmallBagTrackOrderClient.HttpClientName);
services.AddScoped<ISmallBagTrackConfigProvider, SmallBagTrackConfigProvider>(); services.AddScoped<ISmallBagTrackConfigProvider, SmallBagTrackConfigProvider>();
services.AddScoped<ISmallBagTrackUpsertRequestBuilder, SmallBagTrackUpsertRequestBuilder>();
services.AddScoped<ISmallBagTrackOrderClient, SmallBagTrackOrderClient>(); services.AddScoped<ISmallBagTrackOrderClient, SmallBagTrackOrderClient>();
services.AddSingleton<ISmallBagTrackOrderNotifier, SmallBagTrackOrderNotifier>(); services.AddSingleton<ISmallBagTrackOrderNotifier, SmallBagTrackOrderNotifier>();
return services; return services;

View File

@ -18,7 +18,7 @@ public class SmallBagTrackUpsertRequest
public string Product { get; set; } = string.Empty; public string Product { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 轨迹服务商 code(对应轨迹 ServiceProviderCode,取订单 DownstreamSupplierCode)。 /// 轨迹拉取配置 code(对应轨迹 ServiceProviderCode;优先 ChannelForecastCode)。
/// </summary> /// </summary>
public string ApiCode { get; set; } = string.Empty; public string ApiCode { get; set; } = string.Empty;
@ -28,6 +28,11 @@ public class SmallBagTrackUpsertRequest
/// 转发到轨迹网关的 Authorization(异步入队前从当前 HTTP 请求捕获)。 /// 转发到轨迹网关的 Authorization(异步入队前从当前 HTTP 请求捕获)。
/// </summary> /// </summary>
public string? Authorization { get; set; } public string? Authorization { get; set; }
/// <summary>
/// UpsertSmallBag 成功后是否追加创单默认轨迹(A0101/SZ)。
/// </summary>
public bool AppendInitialTrackOnSuccess { get; set; }
} }
public class SmallBagTrackConsigneeRequest public class SmallBagTrackConsigneeRequest

View File

@ -0,0 +1,147 @@
using Fake.DependencyInjection;
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Integrations.SmallBagTrack;
public interface ISmallBagTrackUpsertRequestBuilder
{
Task<SmallBagTrackUpsertRequest> BuildAsync(
Order order,
OrderParcelReceiverInput? receiverInput = null,
Customer? customer = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// 组装 UpsertSmallBag 请求:ApiCode 优先 ChannelForecastCode,TrackNo 缺省用 ServiceNo。
/// </summary>
internal sealed class SmallBagTrackUpsertRequestBuilder(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IServiceChannelRepository serviceChannelRepository,
ISalesProductRepository salesProductRepository) : ISmallBagTrackUpsertRequestBuilder, IScopedDependency
{
public async Task<SmallBagTrackUpsertRequest> BuildAsync(
Order order,
OrderParcelReceiverInput? receiverInput = null,
Customer? customer = null,
CancellationToken cancellationToken = default)
{
customer ??= await ResolveCustomerAsync(order, cancellationToken);
var recipients = await orderRepository.GetRecipientsByOrderIdsAsync([order.Id], cancellationToken);
var recipient = recipients.FirstOrDefault();
var apiCode = await ResolveTrackApiCodeAsync(order, cancellationToken);
return new SmallBagTrackUpsertRequest
{
Client = customer?.CustomerCode?.Trim() ?? string.Empty,
OrderNo = order.ServiceNo?.Trim() ?? string.Empty,
TrackNo = ResolveTrackNo(order),
OtherNo = order.ClientNo?.Trim() ?? string.Empty,
Channel = order.ChannelCode?.Trim() ?? string.Empty,
Product = order.ProductCode?.Trim() ?? string.Empty,
ApiCode = apiCode,
Consignee = new SmallBagTrackConsigneeRequest
{
CountryCode = receiverInput?.CountryCode?.Trim() ?? order.ConsigneeCountry?.Trim() ?? string.Empty,
Province = receiverInput?.Province?.Trim() ?? recipient?.Province?.Trim() ?? string.Empty,
Postcode = receiverInput?.ZipCode?.Trim() ?? recipient?.ZipCode?.Trim() ?? string.Empty,
City = receiverInput?.City?.Trim() ?? recipient?.City?.Trim() ?? string.Empty,
Email = receiverInput?.Email?.Trim() ?? recipient?.Email?.Trim() ?? string.Empty
}
};
}
internal static string ResolveTrackNo(Order order)
{
var trackNo = order.TrackNo?.Trim();
if (!string.IsNullOrWhiteSpace(trackNo))
{
return trackNo;
}
return order.ServiceNo?.Trim() ?? string.Empty;
}
private async Task<Customer?> ResolveCustomerAsync(Order order, CancellationToken cancellationToken)
{
if (!order.CustomerId.HasValue)
{
return null;
}
return await customerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value, cancellationToken);
}
private async Task<string> ResolveTrackApiCodeAsync(Order order, CancellationToken cancellationToken)
{
var fromOrder = order.ChannelForecastCode?.Trim();
if (!string.IsNullOrWhiteSpace(fromOrder))
{
return fromOrder;
}
var channelCode = order.ChannelCode?.Trim();
if (!string.IsNullOrWhiteSpace(channelCode))
{
var channel = await serviceChannelRepository.GetByChannelCodeAsync(channelCode, cancellationToken);
var forecast = channel?.ForecastCode?.Trim();
if (!string.IsNullOrWhiteSpace(forecast))
{
return forecast;
}
}
var provisional = await ResolveProvisionalApiCodeFromProductAsync(order, cancellationToken);
return provisional ?? string.Empty;
}
/// <summary>
/// 创单尚未排货时,用销售产品排货规则里首个渠道的 ForecastCode 作为轨迹 ApiCode 预占位。
/// </summary>
private async Task<string?> ResolveProvisionalApiCodeFromProductAsync(
Order order,
CancellationToken cancellationToken)
{
var productCode = order.ProductCode?.Trim();
if (string.IsNullOrWhiteSpace(productCode))
{
return null;
}
var product = await salesProductRepository.GetByProductCodeAsync(productCode);
if (product == null)
{
return null;
}
var providers = await salesProductRepository.GetAllocationProvidersAsync(product.Id);
var channelIds = providers
.Where(x => x.ServiceChannelId.HasValue)
.Select(x => x.ServiceChannelId!.Value)
.Distinct()
.ToArray();
if (channelIds.Length == 0)
{
return null;
}
var channels = await serviceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id));
foreach (var channel in channels.OrderBy(x => x.Id))
{
var forecast = channel.ForecastCode?.Trim();
if (!string.IsNullOrWhiteSpace(forecast))
{
return forecast;
}
}
return null;
}
}

View File

@ -0,0 +1,43 @@
namespace Oms.Application.Integrations.SmallBagTrack;
internal sealed class SmallBagTrackUpsertTrackApiRequest
{
public string AppKey { get; set; } = string.Empty;
public int OrderType { get; set; }
public string OrderNo { get; set; } = string.Empty;
public bool IsAdd { get; set; } = true;
public string TrackCode { get; set; } = string.Empty;
public string TrackPlace { get; set; } = string.Empty;
public DateTime TrackTime { get; set; }
public float TrackTimeZone { get; set; }
public int Source { get; set; }
public static SmallBagTrackUpsertTrackApiRequest CreateLifecycleEvent(
string appKey,
string orderNo,
string trackCode,
DateTime trackTime) =>
new()
{
AppKey = appKey,
OrderType = SmallBagTrackLifecycleEvents.OrderType,
OrderNo = orderNo,
IsAdd = true,
TrackCode = trackCode,
TrackPlace = SmallBagTrackLifecycleEvents.TrackPlace,
TrackTime = trackTime,
TrackTimeZone = SmallBagTrackLifecycleEvents.TrackTimeZone,
Source = SmallBagTrackLifecycleEvents.Source
};
public static string BuildEndpointUrl(string baseUrl) =>
$"{baseUrl.Trim().TrimEnd('/')}/ProviderService/Track/Upsert";
}

View File

@ -17,6 +17,7 @@ public sealed class OrderBatchActionExecutor(
IOrderRepository orderRepository, IOrderRepository orderRepository,
ICountryRepository countryRepository, ICountryRepository countryRepository,
ISmallBagTrackOrderClient smallBagTrackOrderClient, ISmallBagTrackOrderClient smallBagTrackOrderClient,
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
OrderDownstreamCredentialResolver downstreamCredentialResolver, OrderDownstreamCredentialResolver downstreamCredentialResolver,
IItfxForwardClient itfxForwardClient, IItfxForwardClient itfxForwardClient,
OrderBatchCreateActionHandler createActionHandler, OrderBatchCreateActionHandler createActionHandler,
@ -79,7 +80,7 @@ public sealed class OrderBatchActionExecutor(
return OrderBatchActionExecuteResult.BusinessFail("订单运单号为空,无法同步轨迹"); return OrderBatchActionExecuteResult.BusinessFail("订单运单号为空,无法同步轨迹");
} }
request = await BuildTrackRequestAsync(order, cancellationToken); request = await smallBagTrackUpsertRequestBuilder.BuildAsync(order, cancellationToken: cancellationToken);
} }
await smallBagTrackOrderClient.UpsertAsync(request, cancellationToken); await smallBagTrackOrderClient.UpsertAsync(request, cancellationToken);
@ -147,30 +148,4 @@ public sealed class OrderBatchActionExecutor(
return OrderBatchActionExecuteResult.Ok(); return OrderBatchActionExecuteResult.Ok();
} }
private async Task<SmallBagTrackUpsertRequest> BuildTrackRequestAsync(
Domain.OrdersAggregate.Entities.Order order,
CancellationToken cancellationToken)
{
var recipients = await orderRepository.GetRecipientsByOrderIdsAsync([order.Id], cancellationToken);
var recipient = recipients.FirstOrDefault();
return new SmallBagTrackUpsertRequest
{
OrderNo = order.ServiceNo ?? string.Empty,
TrackNo = order.TrackNo?.Trim() ?? string.Empty,
OtherNo = order.ClientNo?.Trim() ?? string.Empty,
Channel = order.ChannelCode?.Trim() ?? string.Empty,
Product = order.ProductCode?.Trim() ?? string.Empty,
ApiCode = order.DownstreamSupplierCode?.Trim() ?? string.Empty,
Consignee = new SmallBagTrackConsigneeRequest
{
CountryCode = order.ConsigneeCountry?.Trim() ?? string.Empty,
Province = recipient?.Province?.Trim() ?? string.Empty,
Postcode = recipient?.ZipCode?.Trim() ?? string.Empty,
City = recipient?.City?.Trim() ?? string.Empty,
Email = recipient?.Email?.Trim() ?? string.Empty,
}
};
}
} }

View File

@ -366,12 +366,12 @@ public sealed class OrderBatchJobCoordinator(
output.TaskName = string.IsNullOrWhiteSpace(meta.TaskName) ? null : meta.TaskName.Trim(); output.TaskName = string.IsNullOrWhiteSpace(meta.TaskName) ? null : meta.TaskName.Trim();
} }
public async Task PublishTrackUpsertAsync(SmallBagTrackUpsertRequest request, int orderId, int? customerId) public async Task<bool> PublishTrackUpsertAsync(SmallBagTrackUpsertRequest request, int orderId, int? customerId)
{ {
if (!publisher.IsEnabled) if (!publisher.IsEnabled)
{ {
logger.LogDebug("RabbitMQ disabled, skip track upsert publish for order {OrderId}", orderId); logger.LogDebug("RabbitMQ disabled, track upsert will use direct call. orderId={OrderId}", orderId);
return; return false;
} }
var message = new OrderBatchJobMessage var message = new OrderBatchJobMessage
@ -387,6 +387,7 @@ public sealed class OrderBatchJobCoordinator(
}; };
await publisher.PublishAsync(message); await publisher.PublishAsync(message);
return true;
} }
public async Task<OrderBatchJobOutput?> GetJobAsync(int jobId, CancellationToken cancellationToken = default) public async Task<OrderBatchJobOutput?> GetJobAsync(int jobId, CancellationToken cancellationToken = default)

View File

@ -89,7 +89,7 @@ public partial class OrderService
: await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id)); : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
var customerMap = customers.ToDictionary(x => x.Id); var customerMap = customers.ToDictionary(x => x.Id);
return orders.Select(order => var outputs = orders.Select(order =>
{ {
senderMap.TryGetValue(order.Id, out var sender); senderMap.TryGetValue(order.Id, out var sender);
recipientMap.TryGetValue(order.Id, out var recipient); recipientMap.TryGetValue(order.Id, out var recipient);
@ -198,6 +198,26 @@ public partial class OrderService
ReceivableAmount = feeOutputs.Count == 0 ? null : receivableAmount ReceivableAmount = feeOutputs.Count == 0 ? null : receivableAmount
}; };
}).ToList(); }).ToList();
if (OmsSessionHelper.IsCustomerLogin(CurrentUser))
{
RedactChannelFieldsForCustomer(outputs);
}
return outputs;
}
private static void RedactChannelFieldsForCustomer(IEnumerable<OrderOutput> outputs)
{
foreach (var output in outputs)
{
output.ChannelCode = null;
output.ChannelName = null;
output.DownstreamSupplierCode = null;
output.DownstreamAppKey = null;
output.DownstreamAppId = null;
output.ChannelForecastCode = null;
}
} }
private async Task<List<object>> BuildOrderExportRowsAsync(IReadOnlyCollection<Order> orders) private async Task<List<object>> BuildOrderExportRowsAsync(IReadOnlyCollection<Order> orders)

View File

@ -155,7 +155,7 @@ public partial class OrderService
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver); await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver, appendInitialTrack: true);
await EnqueueGenerateLabelPdfAsync(order); await EnqueueGenerateLabelPdfAsync(order);
try try
@ -1625,6 +1625,16 @@ public partial class OrderService
order.ReceiveTime = DateTime.Now; order.ReceiveTime = DateTime.Now;
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.InboundTrackCode);
if (allocated)
{
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
await EnqueueSmallBagTrackUpsertAsync(order, customer, receiver: null);
}
} }
/// <summary> /// <summary>
@ -1706,6 +1716,8 @@ public partial class OrderService
order.ShipTime = now; order.ShipTime = now;
order.UpdateTime = now; order.UpdateTime = now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.OutboundTrackCode);
} }
private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order) private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order)
@ -1764,9 +1776,11 @@ public partial class OrderService
private async Task EnqueueSmallBagTrackUpsertAsync( private async Task EnqueueSmallBagTrackUpsertAsync(
Order order, Order order,
Customer? customer, Customer? customer,
OrderParcelReceiverInput? receiver) OrderParcelReceiverInput? receiver,
bool appendInitialTrack = false)
{ {
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver);
request.AppendInitialTrackOnSuccess = appendInitialTrack;
var authorization = LazyServiceProvider var authorization = LazyServiceProvider
.GetRequiredService<IHttpContextAccessor>() .GetRequiredService<IHttpContextAccessor>()
.HttpContext? .HttpContext?
@ -1782,6 +1796,28 @@ public partial class OrderService
SmallBagTrackOrderNotifier.Enqueue(request, order.Id, customer?.Id); SmallBagTrackOrderNotifier.Enqueue(request, order.Id, customer?.Id);
} }
private void EnqueueLifecycleTrackAppend(Order order, string trackCode)
{
if (string.IsNullOrWhiteSpace(order.ServiceNo))
{
return;
}
var authorization = LazyServiceProvider
.GetRequiredService<IHttpContextAccessor>()
.HttpContext?
.Request
.Headers
.Authorization
.ToString();
SmallBagTrackOrderNotifier.EnqueueLifecycleTrackAppend(
order.ServiceNo,
trackCode,
order.Id,
string.IsNullOrWhiteSpace(authorization) ? null : authorization);
}
/// <summary> /// <summary>
/// 按订单同步轨迹(UpsertSmallBag),返回完整请求与响应便于联调。 /// 按订单同步轨迹(UpsertSmallBag),返回完整请求与响应便于联调。
/// </summary> /// </summary>
@ -1802,37 +1838,12 @@ public partial class OrderService
Order order, Order order,
OrderParcelReceiverInput? receiverInput = null) OrderParcelReceiverInput? receiverInput = null)
{ {
Customer? customer = null; return await SmallBagTrackUpsertRequestBuilder.BuildAsync(order, receiverInput);
if (order.CustomerId.HasValue)
{
customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
}
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
var recipient = recipients.FirstOrDefault();
var apiCode = order.DownstreamSupplierCode?.Trim() ?? string.Empty;
return new SmallBagTrackUpsertRequest
{
Client = customer?.CustomerCode?.Trim() ?? string.Empty,
OrderNo = order.ServiceNo,
TrackNo = order.TrackNo?.Trim() ?? string.Empty,
OtherNo = order.ClientNo?.Trim() ?? string.Empty,
Channel = order.ChannelCode?.Trim() ?? string.Empty,
Product = order.ProductCode?.Trim() ?? string.Empty,
ApiCode = apiCode,
Consignee = new SmallBagTrackConsigneeRequest
{
CountryCode = receiverInput?.CountryCode?.Trim() ?? order.ConsigneeCountry?.Trim() ?? string.Empty,
Province = receiverInput?.Province?.Trim() ?? recipient?.Province?.Trim() ?? string.Empty,
Postcode = receiverInput?.ZipCode?.Trim() ?? recipient?.ZipCode?.Trim() ?? string.Empty,
City = receiverInput?.City?.Trim() ?? recipient?.City?.Trim() ?? string.Empty,
Email = receiverInput?.Email?.Trim() ?? recipient?.Email?.Trim() ?? string.Empty
}
};
} }
private ISmallBagTrackUpsertRequestBuilder SmallBagTrackUpsertRequestBuilder =>
LazyServiceProvider.GetRequiredService<ISmallBagTrackUpsertRequestBuilder>();
private ISmallBagTrackOrderClient SmallBagTrackOrderClient => private ISmallBagTrackOrderClient SmallBagTrackOrderClient =>
LazyServiceProvider.GetRequiredService<ISmallBagTrackOrderClient>(); LazyServiceProvider.GetRequiredService<ISmallBagTrackOrderClient>();

View File

@ -87,6 +87,10 @@ public sealed class OrderE2eFixture : IAsyncLifetime
public void Enqueue(SmallBagTrackUpsertRequest request, int orderId, int? customerId = null) public void Enqueue(SmallBagTrackUpsertRequest request, int orderId, int? customerId = null)
{ {
} }
public void EnqueueLifecycleTrackAppend(string orderNo, string trackCode, int orderId, string? authorization = null)
{
}
} }
} }

View File

@ -0,0 +1,67 @@
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Domain.OrdersAggregate.Entities;
using Xunit;
namespace Oms.Tests;
public class SmallBagTrackUpsertRequestBuilderTests
{
[Fact]
public void ResolveTrackNo_UsesTrackNoWhenPresent()
{
var order = new Order
{
ServiceNo = "IDE2607221859123456",
TrackNo = "LP123456789CN"
};
Assert.Equal("LP123456789CN", SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order));
}
[Fact]
public void ResolveTrackNo_FallsBackToServiceNoWhenTrackNoEmpty()
{
var order = new Order
{
ServiceNo = "IDE2607221859123456",
TrackNo = null
};
Assert.Equal("IDE2607221859123456", SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order));
}
[Fact]
public void CreateLifecycleEvent_UsesConfiguredDefaults()
{
var trackTime = new DateTime(2026, 7, 22, 20, 7, 0);
var body = SmallBagTrackUpsertTrackApiRequest.CreateLifecycleEvent(
"APP001",
"IDE2607221859123456",
SmallBagTrackLifecycleEvents.CreateTrackCode,
trackTime);
Assert.Equal("APP001", body.AppKey);
Assert.Equal("IDE2607221859123456", body.OrderNo);
Assert.True(body.IsAdd);
Assert.Equal("A0101", body.TrackCode);
Assert.Equal("SZ", body.TrackPlace);
Assert.Equal(trackTime, body.TrackTime);
Assert.Equal(8f, body.TrackTimeZone);
Assert.Equal(1, body.OrderType);
Assert.Equal(2, body.Source);
}
[Theory]
[InlineData("A0301")]
[InlineData("A0701")]
public void CreateLifecycleEvent_SupportsInboundAndOutboundCodes(string trackCode)
{
var body = SmallBagTrackUpsertTrackApiRequest.CreateLifecycleEvent(
"APP001",
"IDE2607221859123456",
trackCode,
DateTime.Now);
Assert.Equal(trackCode, body.TrackCode);
}
}