1.04 #6
@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- All Fake packages -->
|
<!-- All Fake packages -->
|
||||||
<FakePackageVersion>8.0.5.19</FakePackageVersion>
|
<FakePackageVersion>8.0.5.20</FakePackageVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
27
src/Oms.Application/Dtos/Orders/ItfxGetFileBody.cs
Normal file
27
src/Oms.Application/Dtos/Orders/ItfxGetFileBody.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
namespace Oms.Application.Dtos.Orders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// itfx getFile(ForwardWithSubscribe)标准请求体。
|
||||||
|
/// </summary>
|
||||||
|
public class ItfxGetFileBody
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 客户单号。
|
||||||
|
/// </summary>
|
||||||
|
public string? ClientNo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 跟踪号。
|
||||||
|
/// </summary>
|
||||||
|
public string? TrackNo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务商单号。
|
||||||
|
/// </summary>
|
||||||
|
public string? ServiceNo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件类型:下游面单=21,末端面单=31,POD=180。
|
||||||
|
/// </summary>
|
||||||
|
public int Type { get; set; }
|
||||||
|
}
|
||||||
@ -1,5 +1,3 @@
|
|||||||
using Oms.Application.Dtos.Orders;
|
|
||||||
|
|
||||||
namespace Oms.Application.Dtos.Orders;
|
namespace Oms.Application.Dtos.Orders;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -43,3 +41,56 @@ public class OrderDownstreamForwardResult
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? TrackNo { get; set; }
|
public string? TrackNo { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下游 getFile 入参。
|
||||||
|
/// </summary>
|
||||||
|
public class OrderDownstreamGetFileInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 订单 Id。
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件类型:下游面单=21,末端面单=31,POD=180。
|
||||||
|
/// </summary>
|
||||||
|
public int Type { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下游 getFile 转发结果。
|
||||||
|
/// </summary>
|
||||||
|
public class OrderDownstreamGetFileResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
|
||||||
|
public string SupplierCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int Type { get; set; }
|
||||||
|
|
||||||
|
public string? FileType { get; set; }
|
||||||
|
|
||||||
|
public string Url { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string RequestJson { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int? StatusCode { get; set; }
|
||||||
|
|
||||||
|
public string ResponseBody { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转存到自有 COS 后的公开地址。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoredFileUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 回写后的服务商单号(Data.serviceNo)。
|
||||||
|
/// </summary>
|
||||||
|
public string? DownstreamServiceNo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 回写后的跟踪号(Data.trackNo)。
|
||||||
|
/// </summary>
|
||||||
|
public string? TrackNo { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
171
src/Oms.Application/Integrations/Itfx/ItfxGetFileDataParser.cs
Normal file
171
src/Oms.Application/Integrations/Itfx/ItfxGetFileDataParser.cs
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
|
||||||
|
namespace Oms.Application.Integrations.Itfx;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析 itfx getFile 成功响应中的 Data,并回写订单单号。
|
||||||
|
/// </summary>
|
||||||
|
internal static class ItfxGetFileDataParser
|
||||||
|
{
|
||||||
|
public const string StandardCode = "getFile";
|
||||||
|
|
||||||
|
public const int TypeDownstreamLabel = 21;
|
||||||
|
public const int TypeLastMileLabel = 31;
|
||||||
|
public const int TypePod = 180;
|
||||||
|
|
||||||
|
public sealed class GetFileData
|
||||||
|
{
|
||||||
|
public string? ServiceNo { get; init; }
|
||||||
|
|
||||||
|
public string? TrackNo { get; init; }
|
||||||
|
|
||||||
|
public string? ClientNo { get; init; }
|
||||||
|
|
||||||
|
public string? Url { get; init; }
|
||||||
|
|
||||||
|
public int? Type { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryResolveFileType(int type, out string fileType)
|
||||||
|
{
|
||||||
|
fileType = type switch
|
||||||
|
{
|
||||||
|
TypeDownstreamLabel => OrderFileTypeCodes.DownstreamLabel,
|
||||||
|
TypeLastMileLabel => OrderFileTypeCodes.LastMileLabel,
|
||||||
|
TypePod => OrderFileTypeCodes.Pod,
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
return !string.IsNullOrEmpty(fileType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParse(string? responseBody, out GetFileData? data)
|
||||||
|
{
|
||||||
|
data = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(responseBody))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(responseBody);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryGetPropertyIgnoreCase(root, out var dataElement, "data", "Data")
|
||||||
|
|| dataElement.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
data = new GetFileData
|
||||||
|
{
|
||||||
|
ServiceNo = ReadString(dataElement, "serviceNo", "ServiceNo"),
|
||||||
|
TrackNo = ReadString(dataElement, "trackNo", "TrackNo"),
|
||||||
|
ClientNo = ReadString(dataElement, "clientNo", "ClientNo"),
|
||||||
|
Url = ReadString(dataElement, "url", "Url"),
|
||||||
|
Type = ReadInt(dataElement, "type", "Type"),
|
||||||
|
};
|
||||||
|
return !string.IsNullOrWhiteSpace(data.ServiceNo)
|
||||||
|
|| !string.IsNullOrWhiteSpace(data.TrackNo)
|
||||||
|
|| !string.IsNullOrWhiteSpace(data.ClientNo)
|
||||||
|
|| !string.IsNullOrWhiteSpace(data.Url);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务商单号 ← Data.serviceNo,跟踪号 ← Data.trackNo;空值不覆盖。
|
||||||
|
/// </summary>
|
||||||
|
public static bool ApplyToOrder(Order order, GetFileData data)
|
||||||
|
{
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(data.ServiceNo))
|
||||||
|
{
|
||||||
|
var value = data.ServiceNo.Trim();
|
||||||
|
if (!string.Equals(order.DownstreamServiceNo, value, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
order.DownstreamServiceNo = value;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(data.TrackNo))
|
||||||
|
{
|
||||||
|
var value = data.TrackNo.Trim();
|
||||||
|
if (!string.Equals(order.TrackNo, value, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
order.TrackNo = value;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetPropertyIgnoreCase(JsonElement root, out JsonElement value, params string[] names)
|
||||||
|
{
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty(name, out value))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var prop in root.EnumerateObject())
|
||||||
|
{
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (prop.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
value = prop.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadString(JsonElement root, params string[] names)
|
||||||
|
{
|
||||||
|
if (!TryGetPropertyIgnoreCase(root, out var prop, names))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prop.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => prop.GetString()?.Trim(),
|
||||||
|
JsonValueKind.Number => prop.GetRawText(),
|
||||||
|
JsonValueKind.Null => null,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ReadInt(JsonElement root, params string[] names)
|
||||||
|
{
|
||||||
|
if (!TryGetPropertyIgnoreCase(root, out var prop, names))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prop.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Number when prop.TryGetInt32(out var n) => n,
|
||||||
|
JsonValueKind.String when int.TryParse(prop.GetString(), out var n) => n,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -67,16 +67,11 @@ internal sealed class SmallBagTrackUpsertRequestBuilder(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string ResolveTrackNo(Order order)
|
/// <summary>
|
||||||
{
|
/// 仅取运单跟踪号;无值则空串,不回落运单号/客户单号等。
|
||||||
var trackNo = order.TrackNo?.Trim();
|
/// </summary>
|
||||||
if (!string.IsNullOrWhiteSpace(trackNo))
|
internal static string ResolveTrackNo(Order order) =>
|
||||||
{
|
order.TrackNo?.Trim() ?? string.Empty;
|
||||||
return trackNo;
|
|
||||||
}
|
|
||||||
|
|
||||||
return order.ServiceNo?.Trim() ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string? PickSupplierCodeFromProviders(
|
internal static string? PickSupplierCodeFromProviders(
|
||||||
IEnumerable<SalesProductAllocationProviderSnapshotDto> providers,
|
IEnumerable<SalesProductAllocationProviderSnapshotDto> providers,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using System.Data;
|
||||||
using Fake.EntityFrameworkCore;
|
using Fake.EntityFrameworkCore;
|
||||||
using Fake.EventBus.Distributed;
|
using Fake.EventBus.Distributed;
|
||||||
using Fake.EventBus.IntegrationEventLog;
|
using Fake.EventBus.IntegrationEventLog;
|
||||||
@ -9,6 +10,7 @@ namespace Oms.Application.Messaging;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 可靠发布 IntegrationEvent:事务 UoW 内写入 Outbox(由 Fake.EventBus.IntegrationEventLog 投递)。
|
/// 可靠发布 IntegrationEvent:事务 UoW 内写入 Outbox(由 Fake.EventBus.IntegrationEventLog 投递)。
|
||||||
|
/// SqlSugar 与 EF 默认各持连接;若环境事务已由 SqlSugar 占用,则改用 RequiresNew 短事务只写 Outbox。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class OmsIntegrationEventPublisher
|
internal static class OmsIntegrationEventPublisher
|
||||||
{
|
{
|
||||||
@ -21,24 +23,41 @@ internal static class OmsIntegrationEventPublisher
|
|||||||
{
|
{
|
||||||
if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager))
|
if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager))
|
||||||
{
|
{
|
||||||
await eventBus.PublishAsync(@event, cancellationToken);
|
try
|
||||||
logger.LogDebug(
|
{
|
||||||
"Outbox enqueued {EventName} Id={EventId} (ambient TX)",
|
await EnsureOutboxDbContextAsync(unitOfWorkManager);
|
||||||
|
await eventBus.PublishAsync(@event, cancellationToken);
|
||||||
|
logger.LogDebug(
|
||||||
|
"Outbox enqueued {EventName} Id={EventId} (ambient TX)",
|
||||||
|
@event.GetType().Name,
|
||||||
|
@event.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (IsTxConnectionMismatch(ex))
|
||||||
|
{
|
||||||
|
// SqlSugar 已在另一条连接上开事务时,EF 无法征用该 DbTransaction。
|
||||||
|
logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"Ambient UoW TX incompatible with EF Outbox; using short outbox TX. Event={EventName} Id={EventId}",
|
||||||
|
@event.GetType().Name,
|
||||||
|
@event.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Publish {EventName} Id={EventId} without ambient transactional UoW; opening short outbox TX",
|
||||||
@event.GetType().Name,
|
@event.GetType().Name,
|
||||||
@event.Id);
|
@event.Id);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无环境事务时开启短事务只写 Outbox,并告警:业务落库与事件非原子。
|
using var uow = unitOfWorkManager.Begin(new UnitOfWorkAttribute
|
||||||
logger.LogWarning(
|
{
|
||||||
"Publish {EventName} Id={EventId} without ambient transactional UoW; opening short outbox TX",
|
IsTransactional = true,
|
||||||
@event.GetType().Name,
|
RequiresNew = true,
|
||||||
@event.Id);
|
IsolationLevel = IsolationLevel.ReadCommitted
|
||||||
|
});
|
||||||
using var uow = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
|
await EnsureOutboxDbContextAsync(unitOfWorkManager);
|
||||||
// 先打开 Outbox DbContext 以产生可征用的 DbTransaction
|
|
||||||
var dbProvider = uow.ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>();
|
|
||||||
await dbProvider.GetDbContextAsync();
|
|
||||||
await eventBus.PublishAsync(@event, cancellationToken);
|
await eventBus.PublishAsync(@event, cancellationToken);
|
||||||
await uow.CompleteAsync(cancellationToken);
|
await uow.CompleteAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
@ -65,4 +84,30 @@ internal static class OmsIntegrationEventPublisher
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 业务写库前先打开 Outbox DbContext,尽量让 EF 先占住环境事务连接,供 SqlSugar 征用。
|
||||||
|
/// </summary>
|
||||||
|
public static Task EnsureOutboxDbContextAsync(IUnitOfWorkManager unitOfWorkManager)
|
||||||
|
{
|
||||||
|
var current = unitOfWorkManager.Current
|
||||||
|
?? throw new InvalidOperationException("No ambient unit of work");
|
||||||
|
var dbProvider = current.ServiceProvider
|
||||||
|
.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>();
|
||||||
|
return dbProvider.GetDbContextAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTxConnectionMismatch(Exception ex)
|
||||||
|
{
|
||||||
|
for (var e = ex; e != null; e = e.InnerException!)
|
||||||
|
{
|
||||||
|
if (e.Message.Contains("not associated with the current connection", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| e.Message.Contains("associated with the current connection", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
using Fake.EventBus;
|
using Fake.EventBus;
|
||||||
using Fake.UnitOfWork;
|
using Fake.UnitOfWork;
|
||||||
|
using Oms.Application.Messaging;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Oms.Application.Services.Orders.Issues;
|
using Oms.Application.Services.Orders.Issues;
|
||||||
|
|
||||||
@ -26,7 +27,7 @@ public sealed class OrderAutoTriggerFireEventHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var unitOfWork = unitOfWorkManager.Begin();
|
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await autoTriggerRuntimeService.FireByOrderIdAsync(
|
await autoTriggerRuntimeService.FireByOrderIdAsync(
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
using Fake.EventBus;
|
using Fake.EventBus;
|
||||||
using Fake.UnitOfWork;
|
using Fake.UnitOfWork;
|
||||||
|
using Oms.Application.Messaging;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Oms.Application.Integrations.SmallBagTrack;
|
using Oms.Application.Integrations.SmallBagTrack;
|
||||||
|
|
||||||
@ -24,7 +25,7 @@ public sealed class OrderLifecycleTrackAppendEventHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var unitOfWork = unitOfWorkManager.Begin();
|
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await trackClient.AppendLifecycleTrackAsync(
|
await trackClient.AppendLifecycleTrackAsync(
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
using Fake.EventBus;
|
using Fake.EventBus;
|
||||||
using Fake.UnitOfWork;
|
using Fake.UnitOfWork;
|
||||||
|
using Oms.Application.Messaging;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Oms.Application.Services.Orders;
|
using Oms.Application.Services.Orders;
|
||||||
using Oms.Domain.OrdersAggregate.Repositories;
|
using Oms.Domain.OrdersAggregate.Repositories;
|
||||||
@ -21,7 +22,7 @@ public sealed class OrderParcelCustoms9610UpsertEventHandler(
|
|||||||
OrderParcelCustoms9610UpsertIntegrationEvent @event,
|
OrderParcelCustoms9610UpsertIntegrationEvent @event,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var unitOfWork = unitOfWorkManager.Begin();
|
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
|
||||||
var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken);
|
var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken);
|
||||||
if (order == null)
|
if (order == null)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
using Fake.EventBus;
|
using Fake.EventBus;
|
||||||
using Fake.UnitOfWork;
|
using Fake.UnitOfWork;
|
||||||
|
using Oms.Application.Messaging;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Oms.Application.Services.Orders;
|
using Oms.Application.Services.Orders;
|
||||||
@ -20,7 +21,7 @@ public sealed class OrderParcelLabelGenerateEventHandler(
|
|||||||
OrderParcelLabelGenerateIntegrationEvent @event,
|
OrderParcelLabelGenerateIntegrationEvent @event,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var unitOfWork = unitOfWorkManager.Begin();
|
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = scopeFactory.CreateScope();
|
||||||
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
|
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
|
||||||
var result = await orderService.GenerateLabelPdfForBatchAsync(@event.OrderId, cancellationToken);
|
var result = await orderService.GenerateLabelPdfForBatchAsync(@event.OrderId, cancellationToken);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
using Fake.EventBus;
|
using Fake.EventBus;
|
||||||
using Fake.UnitOfWork;
|
using Fake.UnitOfWork;
|
||||||
|
using Oms.Application.Messaging;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Services.Orders;
|
using Oms.Application.Services.Orders;
|
||||||
@ -21,7 +22,7 @@ public sealed class OrderParcelPickupAttachEventHandler(
|
|||||||
OrderParcelPickupAttachIntegrationEvent @event,
|
OrderParcelPickupAttachIntegrationEvent @event,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var unitOfWork = unitOfWorkManager.Begin();
|
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await pickupOrderService.CreateForParcelInternalAsync(new PickupOrderCreateInput
|
await pickupOrderService.CreateForParcelInternalAsync(new PickupOrderCreateInput
|
||||||
|
|||||||
@ -2,6 +2,7 @@ using Fake.Application;
|
|||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
using Fake.EventBus;
|
using Fake.EventBus;
|
||||||
using Fake.UnitOfWork;
|
using Fake.UnitOfWork;
|
||||||
|
using Oms.Application.Messaging;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Oms.Application.Integrations.SmallBagTrack;
|
using Oms.Application.Integrations.SmallBagTrack;
|
||||||
@ -24,7 +25,7 @@ public sealed class OrderParcelTrackUpsertEventHandler(
|
|||||||
OrderParcelTrackUpsertIntegrationEvent @event,
|
OrderParcelTrackUpsertIntegrationEvent @event,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var unitOfWork = unitOfWorkManager.Begin();
|
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
|
||||||
|
|
||||||
var request = await ResolveRequestAsync(@event, cancellationToken);
|
var request = await ResolveRequestAsync(@event, cancellationToken);
|
||||||
request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess;
|
request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess;
|
||||||
|
|||||||
@ -6,6 +6,7 @@ using Oms.Application.Dtos.Orders;
|
|||||||
using Oms.Application.Integrations.Itfx;
|
using Oms.Application.Integrations.Itfx;
|
||||||
using Oms.Application.Integrations.SmallBagTrack;
|
using Oms.Application.Integrations.SmallBagTrack;
|
||||||
using Oms.Application.Messaging;
|
using Oms.Application.Messaging;
|
||||||
|
using Oms.Application.Messaging.ParcelCreate;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Application.Storage;
|
using Oms.Application.Storage;
|
||||||
using Oms.Domain.ChannelsAggregate.Repositories;
|
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||||
@ -23,6 +24,7 @@ public sealed class OrderBatchActionExecutor(
|
|||||||
IObjectStorage fileStorage,
|
IObjectStorage fileStorage,
|
||||||
ISmallBagTrackOrderClient smallBagTrackOrderClient,
|
ISmallBagTrackOrderClient smallBagTrackOrderClient,
|
||||||
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
|
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
|
||||||
|
IOrderParcelCreateEventPublisher parcelCreateEventPublisher,
|
||||||
OrderDownstreamCredentialResolver downstreamCredentialResolver,
|
OrderDownstreamCredentialResolver downstreamCredentialResolver,
|
||||||
IItfxForwardClient itfxForwardClient,
|
IItfxForwardClient itfxForwardClient,
|
||||||
OrderFileManager orderFileManager,
|
OrderFileManager orderFileManager,
|
||||||
@ -231,9 +233,31 @@ public sealed class OrderBatchActionExecutor(
|
|||||||
if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
|
if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
|
||||||
&& data != null)
|
&& data != null)
|
||||||
{
|
{
|
||||||
|
var previousTrackNo = order.TrackNo;
|
||||||
if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
|
if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
|
||||||
{
|
{
|
||||||
await orderRepository.UpdateAsync(order, cancellationToken);
|
await orderRepository.UpdateAsync(order, cancellationToken);
|
||||||
|
|
||||||
|
var nextTrackNo = order.TrackNo?.Trim() ?? string.Empty;
|
||||||
|
var prevTrackNo = previousTrackNo?.Trim() ?? string.Empty;
|
||||||
|
if (!string.IsNullOrWhiteSpace(nextTrackNo)
|
||||||
|
&& !string.Equals(nextTrackNo, prevTrackNo, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var trackRequest = await smallBagTrackUpsertRequestBuilder.BuildAsync(
|
||||||
|
order,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
trackRequest.AppendInitialTrackOnSuccess = true;
|
||||||
|
if (!string.IsNullOrWhiteSpace(authorization))
|
||||||
|
{
|
||||||
|
trackRequest.Authorization = authorization.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
await parcelCreateEventPublisher.PublishTrackUpsertAsync(
|
||||||
|
order,
|
||||||
|
trackRequest,
|
||||||
|
customerId: order.CustomerId,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(data.LabelUrl))
|
if (!string.IsNullOrWhiteSpace(data.LabelUrl))
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
|
using Fake.UnitOfWork;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
@ -30,6 +31,7 @@ public sealed class OrderBatchJobCoordinator(
|
|||||||
IObjectStorage fileStorage,
|
IObjectStorage fileStorage,
|
||||||
IOrderBatchJobPublisher publisher,
|
IOrderBatchJobPublisher publisher,
|
||||||
OrderBatchJobProcessor batchJobProcessor,
|
OrderBatchJobProcessor batchJobProcessor,
|
||||||
|
IUnitOfWorkManager unitOfWorkManager,
|
||||||
IOptions<OrderBatchRabbitMqOptions> batchOptions,
|
IOptions<OrderBatchRabbitMqOptions> batchOptions,
|
||||||
ILogger<OrderBatchJobCoordinator> logger) : IScopedDependency
|
ILogger<OrderBatchJobCoordinator> logger) : IScopedDependency
|
||||||
{
|
{
|
||||||
@ -76,6 +78,12 @@ public sealed class OrderBatchJobCoordinator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 先打开 EF Outbox 上下文,尽量让环境事务由 EF 占连接,避免 SqlSugar 先开事务后 Outbox 征用失败。
|
||||||
|
if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager))
|
||||||
|
{
|
||||||
|
await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager);
|
||||||
|
}
|
||||||
|
|
||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
var job = new OrderBatchJob
|
var job = new OrderBatchJob
|
||||||
{
|
{
|
||||||
|
|||||||
@ -3,6 +3,7 @@ using Fake.DependencyInjection;
|
|||||||
using Fake.ObjectStorage;
|
using Fake.ObjectStorage;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
|
using Oms.Application.Services.Storage;
|
||||||
using Oms.Application.Storage;
|
using Oms.Application.Storage;
|
||||||
using Oms.Domain.OrdersAggregate.Consts;
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
using Oms.Domain.OrdersAggregate.Entities;
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
@ -15,7 +16,8 @@ namespace Oms.Application.Services.Orders;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class OrderFileManager(
|
public sealed class OrderFileManager(
|
||||||
IOrderFileRepository orderFileRepository,
|
IOrderFileRepository orderFileRepository,
|
||||||
IObjectStorage fileStorage) : IScopedDependency
|
IObjectStorage fileStorage,
|
||||||
|
IHttpClientFactory httpClientFactory) : IScopedDependency
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> ImageExt =
|
private static readonly HashSet<string> ImageExt =
|
||||||
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
|
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
|
||||||
@ -205,6 +207,162 @@ public sealed class OrderFileManager(
|
|||||||
return MapItem(saved);
|
return MapItem(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下载远端文件转存自有对象存储后写入订单附件。
|
||||||
|
/// </summary>
|
||||||
|
public async Task<OrderFileItemOutput> AddOrReplaceFromRemoteUrlAsync(
|
||||||
|
int orderId,
|
||||||
|
string fileType,
|
||||||
|
string remoteUrl,
|
||||||
|
int operatorUserId,
|
||||||
|
bool replaceExisting = true,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(remoteUrl))
|
||||||
|
{
|
||||||
|
throw new BusinessException("下游文件地址为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OrderFileTypeCodes.IsValid(fileType))
|
||||||
|
{
|
||||||
|
throw new BusinessException("不支持的附件类型");
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = OrderFileTypeCodes.Normalize(fileType);
|
||||||
|
var sourceUrl = remoteUrl.Trim();
|
||||||
|
var stored = await DownloadAndStoreRemoteAsync(orderId, type, sourceUrl, cancellationToken);
|
||||||
|
|
||||||
|
if (replaceExisting)
|
||||||
|
{
|
||||||
|
await orderFileRepository.SoftDeleteByOrderAndTypeAsync(orderId, type, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
|
||||||
|
var dup = existing.FirstOrDefault(x =>
|
||||||
|
string.Equals(x.FileUrl, stored.Key, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(x.FileUrl, sourceUrl, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (dup != null)
|
||||||
|
{
|
||||||
|
return MapItem(dup);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.Count >= OrderFileTypeCodes.GetMaxCount(type))
|
||||||
|
{
|
||||||
|
var oldest = existing.OrderBy(x => x.Id).First();
|
||||||
|
oldest.IsDeleted = true;
|
||||||
|
oldest.UpdateTime = DateTime.Now;
|
||||||
|
await orderFileRepository.UpdateAsync(oldest, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingAfter = replaceExisting
|
||||||
|
? []
|
||||||
|
: await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
|
||||||
|
var entity = new OrderFile
|
||||||
|
{
|
||||||
|
OrderId = orderId,
|
||||||
|
FileType = type,
|
||||||
|
FileName = stored.FileName,
|
||||||
|
FileUrl = stored.Key,
|
||||||
|
ContentType = stored.ContentType,
|
||||||
|
FileSize = stored.Size,
|
||||||
|
Sort = existingAfter.Count,
|
||||||
|
Source = OrderFileSourceCodes.Downstream,
|
||||||
|
Remark = sourceUrl.Length > 500 ? sourceUrl[..500] : sourceUrl,
|
||||||
|
CreateUserId = operatorUserId,
|
||||||
|
CreateTime = DateTime.Now
|
||||||
|
};
|
||||||
|
var saved = await orderFileRepository.InsertAsync(entity, cancellationToken);
|
||||||
|
return MapItem(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string Key, string? FileName, string? ContentType, long Size)> DownloadAndStoreRemoteAsync(
|
||||||
|
int orderId,
|
||||||
|
string fileType,
|
||||||
|
string remoteUrl,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
byte[] bytes;
|
||||||
|
string? contentType;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = httpClientFactory.CreateClient(FileStorageService.UrlFetchClientName);
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, remoteUrl);
|
||||||
|
request.Headers.TryAddWithoutValidation("Accept", "*/*");
|
||||||
|
using var response = await client.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new BusinessException($"下载下游文件失败:HTTP {(int)response.StatusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
|
contentType = response.Content.Headers.ContentType?.MediaType;
|
||||||
|
}
|
||||||
|
catch (BusinessException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new BusinessException($"下载下游文件失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.Length == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException("下游文件内容为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.Length > MaxPdfBytes)
|
||||||
|
{
|
||||||
|
throw new BusinessException("下游文件不能超过 10MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext = GuessExtension(remoteUrl, contentType);
|
||||||
|
var fileName = TryFileNameFromUrl(remoteUrl) ?? $"{fileType}{ext}";
|
||||||
|
var storedName = $"{Guid.NewGuid():N}{ext}";
|
||||||
|
var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}";
|
||||||
|
await using var stream = new MemoryStream(bytes);
|
||||||
|
var key = await fileStorage.SaveObjectAsync(stream, relativeKey, contentType, cancellationToken);
|
||||||
|
return (key, fileName, contentType, bytes.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GuessExtension(string url, string? contentType)
|
||||||
|
{
|
||||||
|
var fromUrl = Path.GetExtension(
|
||||||
|
(url.Contains('?', StringComparison.Ordinal)
|
||||||
|
? url[..url.IndexOf('?', StringComparison.Ordinal)]
|
||||||
|
: url));
|
||||||
|
if (!string.IsNullOrWhiteSpace(fromUrl) && fromUrl.Length <= 8)
|
||||||
|
{
|
||||||
|
return fromUrl.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(contentType))
|
||||||
|
{
|
||||||
|
if (contentType.Contains("pdf", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ".pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.Contains("png", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ".png";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.Contains("jpeg", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| contentType.Contains("jpg", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ".jpg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ".pdf";
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<OrderFileItemOutput>> UploadAsync(
|
public async Task<List<OrderFileItemOutput>> UploadAsync(
|
||||||
int orderId,
|
int orderId,
|
||||||
string fileType,
|
string fileType,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
using System.Data;
|
||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Fake.Security.Claims;
|
using Fake.Security.Claims;
|
||||||
|
using Fake.UnitOfWork;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
@ -21,6 +23,7 @@ public partial class OrderService
|
|||||||
/// 创建订单批量异步任务并入队 RabbitMQ。
|
/// 创建订单批量异步任务并入队 RabbitMQ。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/jobs")]
|
[HttpPost("/order/batch/jobs")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> CreateBatchJobAsync([FromBody] OrderBatchJobCreateInput input)
|
public Task<OrderBatchJobOutput> CreateBatchJobAsync([FromBody] OrderBatchJobCreateInput input)
|
||||||
{
|
{
|
||||||
return BatchJobCoordinator.CreateAndEnqueueAsync(input, CurrentUser.GetId<int>());
|
return BatchJobCoordinator.CreateAndEnqueueAsync(input, CurrentUser.GetId<int>());
|
||||||
@ -73,6 +76,7 @@ public partial class OrderService
|
|||||||
/// 异步批量创建轨迹订单(替代前端逐条调用)。
|
/// 异步批量创建轨迹订单(替代前端逐条调用)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/track-upsert")]
|
[HttpPost("/order/batch/track-upsert")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchTrackUpsertAsync([FromBody] OrderBatchJobCreateInput input)
|
public Task<OrderBatchJobOutput> BatchTrackUpsertAsync([FromBody] OrderBatchJobCreateInput input)
|
||||||
{
|
{
|
||||||
input.Action = OrderBatchActionCodes.TrackUpsertSmallBag;
|
input.Action = OrderBatchActionCodes.TrackUpsertSmallBag;
|
||||||
@ -94,6 +98,7 @@ public partial class OrderService
|
|||||||
/// 异步批量预报下游订单。
|
/// 异步批量预报下游订单。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/downstream-forward")]
|
[HttpPost("/order/batch/downstream-forward")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchDownstreamForwardAsync([FromBody] OrderBatchJobCreateInput input)
|
public Task<OrderBatchJobOutput> BatchDownstreamForwardAsync([FromBody] OrderBatchJobCreateInput input)
|
||||||
{
|
{
|
||||||
input.Action = OrderBatchActionCodes.DownstreamForwardCreateOne;
|
input.Action = OrderBatchActionCodes.DownstreamForwardCreateOne;
|
||||||
@ -111,6 +116,7 @@ public partial class OrderService
|
|||||||
string.IsNullOrWhiteSpace(authorization) ? null : authorization);
|
string.IsNullOrWhiteSpace(authorization) ? null : authorization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
private Task<OrderBatchJobOutput> EnqueueOrderBatchAsync(string action, OrderBatchJobCreateInput input)
|
private Task<OrderBatchJobOutput> EnqueueOrderBatchAsync(string action, OrderBatchJobCreateInput input)
|
||||||
{
|
{
|
||||||
input.Action = action;
|
input.Action = action;
|
||||||
@ -121,6 +127,7 @@ public partial class OrderService
|
|||||||
/// 异步批量支付订单。
|
/// 异步批量支付订单。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/pay")]
|
[HttpPost("/order/batch/pay")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchPayAsync([FromBody] OrderBatchJobCreateInput input) =>
|
public Task<OrderBatchJobOutput> BatchPayAsync([FromBody] OrderBatchJobCreateInput input) =>
|
||||||
EnqueueOrderBatchAsync(OrderBatchActionCodes.Pay, input);
|
EnqueueOrderBatchAsync(OrderBatchActionCodes.Pay, input);
|
||||||
|
|
||||||
@ -128,6 +135,7 @@ public partial class OrderService
|
|||||||
/// 异步批量预报订单。
|
/// 异步批量预报订单。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/pre-declare")]
|
[HttpPost("/order/batch/pre-declare")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchPreDeclareAsync([FromBody] OrderBatchJobCreateInput input) =>
|
public Task<OrderBatchJobOutput> BatchPreDeclareAsync([FromBody] OrderBatchJobCreateInput input) =>
|
||||||
EnqueueOrderBatchAsync(OrderBatchActionCodes.PreDeclare, input);
|
EnqueueOrderBatchAsync(OrderBatchActionCodes.PreDeclare, input);
|
||||||
|
|
||||||
@ -135,6 +143,7 @@ public partial class OrderService
|
|||||||
/// 异步批量拦截订单。
|
/// 异步批量拦截订单。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/intercept")]
|
[HttpPost("/order/batch/intercept")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchInterceptAsync([FromBody] OrderBatchJobCreateInput input) =>
|
public Task<OrderBatchJobOutput> BatchInterceptAsync([FromBody] OrderBatchJobCreateInput input) =>
|
||||||
EnqueueOrderBatchAsync(OrderBatchActionCodes.Intercept, input);
|
EnqueueOrderBatchAsync(OrderBatchActionCodes.Intercept, input);
|
||||||
|
|
||||||
@ -142,6 +151,7 @@ public partial class OrderService
|
|||||||
/// 异步批量取消拦截。
|
/// 异步批量取消拦截。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/cancel-intercept")]
|
[HttpPost("/order/batch/cancel-intercept")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchCancelInterceptAsync([FromBody] OrderBatchJobCreateInput input) =>
|
public Task<OrderBatchJobOutput> BatchCancelInterceptAsync([FromBody] OrderBatchJobCreateInput input) =>
|
||||||
EnqueueOrderBatchAsync(OrderBatchActionCodes.CancelIntercept, input);
|
EnqueueOrderBatchAsync(OrderBatchActionCodes.CancelIntercept, input);
|
||||||
|
|
||||||
@ -149,6 +159,7 @@ public partial class OrderService
|
|||||||
/// 异步批量删除订单。
|
/// 异步批量删除订单。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/delete")]
|
[HttpPost("/order/batch/delete")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchDeleteAsync([FromBody] OrderBatchJobCreateInput input) =>
|
public Task<OrderBatchJobOutput> BatchDeleteAsync([FromBody] OrderBatchJobCreateInput input) =>
|
||||||
EnqueueOrderBatchAsync(OrderBatchActionCodes.Delete, input);
|
EnqueueOrderBatchAsync(OrderBatchActionCodes.Delete, input);
|
||||||
|
|
||||||
@ -156,6 +167,7 @@ public partial class OrderService
|
|||||||
/// 异步批量打印面单。
|
/// 异步批量打印面单。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/print")]
|
[HttpPost("/order/batch/print")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchPrintAsync([FromBody] OrderBatchJobCreateInput input) =>
|
public Task<OrderBatchJobOutput> BatchPrintAsync([FromBody] OrderBatchJobCreateInput input) =>
|
||||||
EnqueueOrderBatchAsync(OrderBatchActionCodes.Print, input);
|
EnqueueOrderBatchAsync(OrderBatchActionCodes.Print, input);
|
||||||
|
|
||||||
@ -163,6 +175,7 @@ public partial class OrderService
|
|||||||
/// 异步批量标记问题件。
|
/// 异步批量标记问题件。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/mark-problematic")]
|
[HttpPost("/order/batch/mark-problematic")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchMarkProblematicAsync([FromBody] OrderBatchJobCreateInput input) =>
|
public Task<OrderBatchJobOutput> BatchMarkProblematicAsync([FromBody] OrderBatchJobCreateInput input) =>
|
||||||
EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkProblematic, input);
|
EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkProblematic, input);
|
||||||
|
|
||||||
@ -170,6 +183,7 @@ public partial class OrderService
|
|||||||
/// 异步批量重新排货。
|
/// 异步批量重新排货。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/reallocate")]
|
[HttpPost("/order/batch/reallocate")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchReallocateAsync([FromBody] OrderBatchJobCreateInput input) =>
|
public Task<OrderBatchJobOutput> BatchReallocateAsync([FromBody] OrderBatchJobCreateInput input) =>
|
||||||
EnqueueOrderBatchAsync(OrderBatchActionCodes.Reallocate, input);
|
EnqueueOrderBatchAsync(OrderBatchActionCodes.Reallocate, input);
|
||||||
|
|
||||||
@ -177,6 +191,7 @@ public partial class OrderService
|
|||||||
/// 异步批量标记已认领。
|
/// 异步批量标记已认领。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/mark-claimed")]
|
[HttpPost("/order/batch/mark-claimed")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchMarkClaimedAsync([FromBody] OrderBatchJobCreateInput input) =>
|
public Task<OrderBatchJobOutput> BatchMarkClaimedAsync([FromBody] OrderBatchJobCreateInput input) =>
|
||||||
EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkClaimed, input);
|
EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkClaimed, input);
|
||||||
|
|
||||||
@ -184,6 +199,7 @@ public partial class OrderService
|
|||||||
/// 异步批量更新处理状态。
|
/// 异步批量更新处理状态。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/batch/handle-status-update")]
|
[HttpPost("/order/batch/handle-status-update")]
|
||||||
|
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
|
||||||
public Task<OrderBatchJobOutput> BatchHandleStatusUpdateAsync([FromBody] OrderBatchJobCreateInput input) =>
|
public Task<OrderBatchJobOutput> BatchHandleStatusUpdateAsync([FromBody] OrderBatchJobCreateInput input) =>
|
||||||
EnqueueOrderBatchAsync(OrderBatchActionCodes.HandleStatusUpdate, input);
|
EnqueueOrderBatchAsync(OrderBatchActionCodes.HandleStatusUpdate, input);
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Fake.Domain.Exceptions;
|
using Fake.Domain.Exceptions;
|
||||||
|
using Fake.UnitOfWork;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Integrations.Itfx;
|
using Oms.Application.Integrations.Itfx;
|
||||||
@ -36,6 +37,7 @@ public partial class OrderService
|
|||||||
/// 按订单 Id 组装 createOrderOne 并转发至 itfx 下游。
|
/// 按订单 Id 组装 createOrderOne 并转发至 itfx 下游。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("/order/downstream-forward-create-one")]
|
[HttpPost("/order/downstream-forward-create-one")]
|
||||||
|
[UnitOfWork(IsTransactional = true)]
|
||||||
public async Task<OrderDownstreamForwardResult> DownstreamForwardCreateOneAsync([FromBody] OrderActionInput input)
|
public async Task<OrderDownstreamForwardResult> DownstreamForwardCreateOneAsync([FromBody] OrderActionInput input)
|
||||||
{
|
{
|
||||||
var (order, body) = await LoadDownstreamCreateOneContextAsync(input.Id);
|
var (order, body) = await LoadDownstreamCreateOneContextAsync(input.Id);
|
||||||
@ -55,9 +57,11 @@ public partial class OrderService
|
|||||||
&& ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
|
&& ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
|
||||||
&& data != null)
|
&& data != null)
|
||||||
{
|
{
|
||||||
|
var previousTrackNo = order.TrackNo;
|
||||||
if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
|
if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
|
||||||
{
|
{
|
||||||
await OrderRepository.UpdateAsync(order);
|
await OrderRepository.UpdateAsync(order);
|
||||||
|
await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(data.LabelUrl))
|
if (!string.IsNullOrWhiteSpace(data.LabelUrl))
|
||||||
@ -82,6 +86,74 @@ public partial class OrderService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按订单 Id 组装 getFile 并转发至 itfx 下游;成功时转存文件并回写单号。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/order/downstream-forward-get-file")]
|
||||||
|
public async Task<OrderDownstreamGetFileResult> DownstreamForwardGetFileAsync(
|
||||||
|
[FromBody] OrderDownstreamGetFileInput input)
|
||||||
|
{
|
||||||
|
if (!ItfxGetFileDataParser.TryResolveFileType(input.Type, out var fileType))
|
||||||
|
{
|
||||||
|
throw new BusinessException("不支持的文件类型,请选择下游面单/末端面单/POD");
|
||||||
|
}
|
||||||
|
|
||||||
|
var order = await GetOrderOrThrowAsync(input.Id);
|
||||||
|
var creds = await OrderDownstreamCredentialResolver.ResolveAsync(order);
|
||||||
|
var body = new ItfxGetFileBody
|
||||||
|
{
|
||||||
|
ClientNo = order.ClientNo?.Trim() ?? string.Empty,
|
||||||
|
TrackNo = order.TrackNo?.Trim() ?? string.Empty,
|
||||||
|
ServiceNo = order.DownstreamServiceNo?.Trim() ?? string.Empty,
|
||||||
|
Type = input.Type
|
||||||
|
};
|
||||||
|
|
||||||
|
var forward = await ItfxForwardClient.ForwardWithSubscribeAsync(
|
||||||
|
ItfxGetFileDataParser.StandardCode,
|
||||||
|
creds.SupplierCode,
|
||||||
|
creds.AppKey,
|
||||||
|
creds.AppSecret,
|
||||||
|
body);
|
||||||
|
|
||||||
|
string? storedFileUrl = null;
|
||||||
|
if (forward.Success
|
||||||
|
&& ItfxGetFileDataParser.TryParse(forward.ResponseBody, out var data)
|
||||||
|
&& data != null)
|
||||||
|
{
|
||||||
|
var previousTrackNo = order.TrackNo;
|
||||||
|
if (ItfxGetFileDataParser.ApplyToOrder(order, data))
|
||||||
|
{
|
||||||
|
await OrderRepository.UpdateAsync(order);
|
||||||
|
await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(data.Url))
|
||||||
|
{
|
||||||
|
var saved = await OrderFileManager.AddOrReplaceFromRemoteUrlAsync(
|
||||||
|
order.Id,
|
||||||
|
fileType,
|
||||||
|
data.Url!,
|
||||||
|
CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
|
||||||
|
storedFileUrl = saved.FileUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OrderDownstreamGetFileResult
|
||||||
|
{
|
||||||
|
Success = forward.Success,
|
||||||
|
SupplierCode = creds.SupplierCode,
|
||||||
|
Type = input.Type,
|
||||||
|
FileType = fileType,
|
||||||
|
Url = forward.Url,
|
||||||
|
RequestJson = forward.RequestJson,
|
||||||
|
StatusCode = forward.StatusCode,
|
||||||
|
ResponseBody = forward.ResponseBody,
|
||||||
|
StoredFileUrl = storedFileUrl,
|
||||||
|
DownstreamServiceNo = order.DownstreamServiceNo,
|
||||||
|
TrackNo = order.TrackNo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(Domain.OrdersAggregate.Entities.Order order, ItfxCreateOrderOneBody body)> LoadDownstreamCreateOneContextAsync(
|
private async Task<(Domain.OrdersAggregate.Entities.Order order, ItfxCreateOrderOneBody body)> LoadDownstreamCreateOneContextAsync(
|
||||||
int orderId)
|
int orderId)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -48,6 +48,7 @@ public partial class OrderService
|
|||||||
order.ClientNo = newClientNo;
|
order.ClientNo = newClientNo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var previousTrackNo = order.TrackNo;
|
||||||
if (input.TrackNo != null)
|
if (input.TrackNo != null)
|
||||||
{
|
{
|
||||||
order.TrackNo = string.IsNullOrWhiteSpace(input.TrackNo) ? null : input.TrackNo.Trim();
|
order.TrackNo = string.IsNullOrWhiteSpace(input.TrackNo) ? null : input.TrackNo.Trim();
|
||||||
@ -155,6 +156,11 @@ public partial class OrderService
|
|||||||
afterSnap,
|
afterSnap,
|
||||||
remark);
|
remark);
|
||||||
|
|
||||||
|
if (input.TrackNo != null)
|
||||||
|
{
|
||||||
|
await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo);
|
||||||
|
}
|
||||||
|
|
||||||
return (await MapOrderOutputsAsync([order])).First();
|
return (await MapOrderOutputsAsync([order])).First();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3201,6 +3201,25 @@ public partial class OrderService
|
|||||||
await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
|
await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 跟踪号相对旧值有变更且新值非空时,再创建/更新一次轨迹订单。
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(
|
||||||
|
Order order,
|
||||||
|
string? previousTrackNo,
|
||||||
|
bool appendInitialTrack = true)
|
||||||
|
{
|
||||||
|
var next = order.TrackNo?.Trim() ?? string.Empty;
|
||||||
|
var prev = previousTrackNo?.Trim() ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(next)
|
||||||
|
|| string.Equals(next, prev, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnqueueSmallBagTrackUpsertAsync(order, customer: null, receiver: null, appendInitialTrack);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task EnqueueLifecycleTrackAppendAsync(Order order, string trackCode)
|
private async Task EnqueueLifecycleTrackAppendAsync(Order order, string trackCode)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(order.ServiceNo))
|
if (string.IsNullOrWhiteSpace(order.ServiceNo))
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Fake.ObjectStorage;
|
using Fake.ObjectStorage;
|
||||||
|
using Fake.UnitOfWork;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Moq;
|
using Moq;
|
||||||
@ -36,6 +37,8 @@ public class OrderBatchJobCoordinatorGovernanceTests
|
|||||||
actionExecutor.Object,
|
actionExecutor.Object,
|
||||||
batchOptions,
|
batchOptions,
|
||||||
NullLogger<OrderBatchJobProcessor>.Instance);
|
NullLogger<OrderBatchJobProcessor>.Instance);
|
||||||
|
var unitOfWorkManager = new Mock<IUnitOfWorkManager>(MockBehavior.Loose);
|
||||||
|
unitOfWorkManager.SetupGet(x => x.Current).Returns((IUnitOfWork?)null);
|
||||||
_coordinator = new OrderBatchJobCoordinator(
|
_coordinator = new OrderBatchJobCoordinator(
|
||||||
_batchJobRepository.Object,
|
_batchJobRepository.Object,
|
||||||
_orderRepository.Object,
|
_orderRepository.Object,
|
||||||
@ -44,6 +47,7 @@ public class OrderBatchJobCoordinatorGovernanceTests
|
|||||||
_fileStorage.Object,
|
_fileStorage.Object,
|
||||||
_publisher.Object,
|
_publisher.Object,
|
||||||
processor,
|
processor,
|
||||||
|
unitOfWorkManager.Object,
|
||||||
batchOptions,
|
batchOptions,
|
||||||
NullLogger<OrderBatchJobCoordinator>.Instance);
|
NullLogger<OrderBatchJobCoordinator>.Instance);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ public class SmallBagTrackUpsertRequestBuilderTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveTrackNo_FallsBackToServiceNoWhenTrackNoEmpty()
|
public void ResolveTrackNo_EmptyWhenTrackNoMissing()
|
||||||
{
|
{
|
||||||
var order = new Order
|
var order = new Order
|
||||||
{
|
{
|
||||||
@ -28,7 +28,7 @@ public class SmallBagTrackUpsertRequestBuilderTests
|
|||||||
TrackNo = null
|
TrackNo = null
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert.Equal("IDE2607221859123456", SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order));
|
Assert.Equal(string.Empty, SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user