diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs
new file mode 100644
index 0000000..9a63e80
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Http;
+
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 自动触发规则 Excel 导入入参。
+///
+public class AutoTriggerImportInput
+{
+ ///
+ /// 导入模式:incremental=增量;full=全量(先清空再导入)。
+ ///
+ public string ImportMode { get; set; } = ImportModes.Incremental;
+
+ ///
+ /// Excel 文件。
+ ///
+ public IFormFile File { get; set; } = null!;
+
+ public static class ImportModes
+ {
+ public const string Incremental = "incremental";
+ public const string Full = "full";
+ }
+}
diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs
new file mode 100644
index 0000000..e73fe1e
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs
@@ -0,0 +1,25 @@
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 自动触发规则 Excel 导入结果。
+///
+public class AutoTriggerImportOutput
+{
+ public int SuccessCount { get; set; }
+
+ public int SkipCount { get; set; }
+
+ public int FailCount { get; set; }
+
+ public List Failures { get; set; } = [];
+}
+
+///
+/// 导入失败行。
+///
+public class AutoTriggerImportFailRow
+{
+ public int RowNumber { get; set; }
+
+ public string Reason { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Dtos/Orders/ItfxGetFileBody.cs b/src/Oms.Application/Dtos/Orders/ItfxGetFileBody.cs
new file mode 100644
index 0000000..f97311c
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/ItfxGetFileBody.cs
@@ -0,0 +1,27 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// itfx getFile(ForwardWithSubscribe)标准请求体。
+///
+public class ItfxGetFileBody
+{
+ ///
+ /// 客户单号。
+ ///
+ public string? ClientNo { get; set; }
+
+ ///
+ /// 跟踪号。
+ ///
+ public string? TrackNo { get; set; }
+
+ ///
+ /// 服务商单号。
+ ///
+ public string? ServiceNo { get; set; }
+
+ ///
+ /// 文件类型:下游面单=21,末端面单=31,POD=180。
+ ///
+ public int Type { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs b/src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs
index c69e78c..0ace4b1 100644
--- a/src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderDownstreamForwardDtos.cs
@@ -1,5 +1,3 @@
-using Oms.Application.Dtos.Orders;
-
namespace Oms.Application.Dtos.Orders;
///
@@ -43,3 +41,56 @@ public class OrderDownstreamForwardResult
///
public string? TrackNo { get; set; }
}
+
+///
+/// 下游 getFile 入参。
+///
+public class OrderDownstreamGetFileInput
+{
+ ///
+ /// 订单 Id。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 文件类型:下游面单=21,末端面单=31,POD=180。
+ ///
+ public int Type { get; set; }
+}
+
+///
+/// 下游 getFile 转发结果。
+///
+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;
+
+ ///
+ /// 转存到自有 COS 后的公开地址。
+ ///
+ public string? StoredFileUrl { get; set; }
+
+ ///
+ /// 回写后的服务商单号(Data.serviceNo)。
+ ///
+ public string? DownstreamServiceNo { get; set; }
+
+ ///
+ /// 回写后的跟踪号(Data.trackNo)。
+ ///
+ public string? TrackNo { get; set; }
+}
diff --git a/src/Oms.Application/Integrations/Itfx/ItfxGetFileDataParser.cs b/src/Oms.Application/Integrations/Itfx/ItfxGetFileDataParser.cs
new file mode 100644
index 0000000..4764ab0
--- /dev/null
+++ b/src/Oms.Application/Integrations/Itfx/ItfxGetFileDataParser.cs
@@ -0,0 +1,171 @@
+using System.Text.Json;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Application.Integrations.Itfx;
+
+///
+/// 解析 itfx getFile 成功响应中的 Data,并回写订单单号。
+///
+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;
+ }
+ }
+
+ ///
+ /// 服务商单号 ← Data.serviceNo,跟踪号 ← Data.trackNo;空值不覆盖。
+ ///
+ 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
+ };
+ }
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs
index 7b79ebe..19eef09 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs
@@ -67,16 +67,11 @@ internal sealed class SmallBagTrackUpsertRequestBuilder(
};
}
- internal static string ResolveTrackNo(Order order)
- {
- var trackNo = order.TrackNo?.Trim();
- if (!string.IsNullOrWhiteSpace(trackNo))
- {
- return trackNo;
- }
-
- return order.ServiceNo?.Trim() ?? string.Empty;
- }
+ ///
+ /// 仅取运单跟踪号;无值则空串,不回落运单号/客户单号等。
+ ///
+ internal static string ResolveTrackNo(Order order) =>
+ order.TrackNo?.Trim() ?? string.Empty;
internal static string? PickSupplierCodeFromProviders(
IEnumerable providers,
diff --git a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs
index 0f12f49..956bc1e 100644
--- a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs
+++ b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs
@@ -1,3 +1,4 @@
+using System.Data;
using Fake.EntityFrameworkCore;
using Fake.EventBus.Distributed;
using Fake.EventBus.IntegrationEventLog;
@@ -9,6 +10,7 @@ namespace Oms.Application.Messaging;
///
/// 可靠发布 IntegrationEvent:事务 UoW 内写入 Outbox(由 Fake.EventBus.IntegrationEventLog 投递)。
+/// SqlSugar 与 EF 默认各持连接;若环境事务已由 SqlSugar 占用,则改用 RequiresNew 短事务只写 Outbox。
///
internal static class OmsIntegrationEventPublisher
{
@@ -21,24 +23,41 @@ internal static class OmsIntegrationEventPublisher
{
if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager))
{
- await eventBus.PublishAsync(@event, cancellationToken);
- logger.LogDebug(
- "Outbox enqueued {EventName} Id={EventId} (ambient TX)",
+ try
+ {
+ 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.Id);
- return;
}
- // 无环境事务时开启短事务只写 Outbox,并告警:业务落库与事件非原子。
- logger.LogWarning(
- "Publish {EventName} Id={EventId} without ambient transactional UoW; opening short outbox TX",
- @event.GetType().Name,
- @event.Id);
-
- using var uow = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
- // 先打开 Outbox DbContext 以产生可征用的 DbTransaction
- var dbProvider = uow.ServiceProvider.GetRequiredService>();
- await dbProvider.GetDbContextAsync();
+ using var uow = unitOfWorkManager.Begin(new UnitOfWorkAttribute
+ {
+ IsTransactional = true,
+ RequiresNew = true,
+ IsolationLevel = IsolationLevel.ReadCommitted
+ });
+ await EnsureOutboxDbContextAsync(unitOfWorkManager);
await eventBus.PublishAsync(@event, cancellationToken);
await uow.CompleteAsync(cancellationToken);
}
@@ -65,4 +84,30 @@ internal static class OmsIntegrationEventPublisher
return false;
}
}
+
+ ///
+ /// 业务写库前先打开 Outbox DbContext,尽量让 EF 先占住环境事务连接,供 SqlSugar 征用。
+ ///
+ public static Task EnsureOutboxDbContextAsync(IUnitOfWorkManager unitOfWorkManager)
+ {
+ var current = unitOfWorkManager.Current
+ ?? throw new InvalidOperationException("No ambient unit of work");
+ var dbProvider = current.ServiceProvider
+ .GetRequiredService>();
+ 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;
+ }
}
diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs
index 670c52e..bebb13f 100644
--- a/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs
+++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs
@@ -1,6 +1,7 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
+using Oms.Application.Messaging;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders.Issues;
@@ -26,7 +27,7 @@ public sealed class OrderAutoTriggerFireEventHandler(
return;
}
- using var unitOfWork = unitOfWorkManager.Begin();
+ using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
try
{
await autoTriggerRuntimeService.FireByOrderIdAsync(
diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs
index 2114097..f963a48 100644
--- a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs
+++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs
@@ -1,6 +1,7 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
+using Oms.Application.Messaging;
using Microsoft.Extensions.Logging;
using Oms.Application.Integrations.SmallBagTrack;
@@ -24,7 +25,7 @@ public sealed class OrderLifecycleTrackAppendEventHandler(
return;
}
- using var unitOfWork = unitOfWorkManager.Begin();
+ using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
try
{
await trackClient.AppendLifecycleTrackAsync(
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs
index bb292d2..5ce44e7 100644
--- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs
@@ -1,6 +1,7 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
+using Oms.Application.Messaging;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders;
using Oms.Domain.OrdersAggregate.Repositories;
@@ -21,7 +22,7 @@ public sealed class OrderParcelCustoms9610UpsertEventHandler(
OrderParcelCustoms9610UpsertIntegrationEvent @event,
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);
if (order == null)
{
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs
index 8e29720..78936ca 100644
--- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs
@@ -1,6 +1,7 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
+using Oms.Application.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders;
@@ -20,7 +21,7 @@ public sealed class OrderParcelLabelGenerateEventHandler(
OrderParcelLabelGenerateIntegrationEvent @event,
CancellationToken cancellationToken)
{
- using var unitOfWork = unitOfWorkManager.Begin();
+ using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
using var scope = scopeFactory.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService();
var result = await orderService.GenerateLabelPdfForBatchAsync(@event.OrderId, cancellationToken);
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs
index 488e95c..adcaf40 100644
--- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs
@@ -1,6 +1,7 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
+using Oms.Application.Messaging;
using Microsoft.Extensions.Logging;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders;
@@ -21,7 +22,7 @@ public sealed class OrderParcelPickupAttachEventHandler(
OrderParcelPickupAttachIntegrationEvent @event,
CancellationToken cancellationToken)
{
- using var unitOfWork = unitOfWorkManager.Begin();
+ using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
try
{
await pickupOrderService.CreateForParcelInternalAsync(new PickupOrderCreateInput
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs
index 6347a3c..0fb3f89 100644
--- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs
@@ -2,6 +2,7 @@ using Fake.Application;
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
+using Oms.Application.Messaging;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Oms.Application.Integrations.SmallBagTrack;
@@ -24,7 +25,7 @@ public sealed class OrderParcelTrackUpsertEventHandler(
OrderParcelTrackUpsertIntegrationEvent @event,
CancellationToken cancellationToken)
{
- using var unitOfWork = unitOfWorkManager.Begin();
+ using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
var request = await ResolveRequestAsync(@event, cancellationToken);
request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess;
diff --git a/src/Oms.Application/Services/Channels/AutoTriggerService.cs b/src/Oms.Application/Services/Channels/AutoTriggerService.cs
index 97477f5..a5cad73 100644
--- a/src/Oms.Application/Services/Channels/AutoTriggerService.cs
+++ b/src/Oms.Application/Services/Channels/AutoTriggerService.cs
@@ -38,6 +38,118 @@ public class AutoTriggerService : ApplicationService
return new OmsPageResult { Items = items, Total = total };
}
+ ///
+ /// 下载自动触发规则导入模板。
+ ///
+ [HttpGet("/channel/auto-trigger/import-template")]
+ public async Task DownloadImportTemplateAsync()
+ {
+ var bytes = await AutoTriggerImportHelper.BuildTemplateBytesAsync();
+ return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+ {
+ FileDownloadName = AutoTriggerImportHelper.TemplateFileName
+ };
+ }
+
+ ///
+ /// 导入自动触发规则(Excel)。
+ ///
+ [HttpPost("/channel/auto-trigger/import")]
+ public async Task ImportAsync([FromForm] AutoTriggerImportInput input)
+ {
+ if (input.File == null || input.File.Length == 0)
+ {
+ throw new BusinessException("请上传 Excel 文件");
+ }
+
+ var importMode = NormalizeImportMode(input.ImportMode);
+ await using var stream = input.File.OpenReadStream();
+ var parsedRows = await AutoTriggerImportHelper.ParseImportRowsAsync(stream);
+ if (parsedRows.Count == 0)
+ {
+ throw new BusinessException("Excel 中无有效数据行");
+ }
+
+ if (importMode == AutoTriggerImportInput.ImportModes.Full)
+ {
+ var existing = await AutoTriggerRepository.GetListAsync(_ => true);
+ foreach (var entity in existing)
+ {
+ await AutoTriggerRepository.DeleteAsync(entity);
+ }
+ }
+
+ var output = new AutoTriggerImportOutput();
+ var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var (rowNumber, entity) in parsedRows)
+ {
+ try
+ {
+ AutoTriggerImportHelper.ValidateEntity(entity);
+ entity.TriggerTiming = AutoTriggerTimingCodes.ToCanonicalLabel(entity.TriggerTiming);
+ var key = BuildDuplicateKey(entity);
+ if (!seenKeys.Add(key))
+ {
+ output.SkipCount++;
+ output.Failures.Add(new AutoTriggerImportFailRow
+ {
+ RowNumber = rowNumber,
+ Reason = "文件内规则重复,已跳过"
+ });
+ continue;
+ }
+
+ if (importMode == AutoTriggerImportInput.ImportModes.Incremental)
+ {
+ var duplicated = await AutoTriggerRepository.FirstOrDefaultAsync(x =>
+ x.RuleName == entity.RuleName
+ && x.Operation == entity.Operation
+ && x.TriggerTiming == entity.TriggerTiming
+ && x.TriggerCondition == entity.TriggerCondition);
+ if (duplicated != null)
+ {
+ output.SkipCount++;
+ output.Failures.Add(new AutoTriggerImportFailRow
+ {
+ RowNumber = rowNumber,
+ Reason = "与已有规则重复,已跳过"
+ });
+ continue;
+ }
+ }
+
+ await AutoTriggerRepository.InsertAsync(entity);
+ output.SuccessCount++;
+ }
+ catch (Exception ex)
+ {
+ output.FailCount++;
+ output.Failures.Add(new AutoTriggerImportFailRow
+ {
+ RowNumber = rowNumber,
+ Reason = ex.Message
+ });
+ }
+ }
+
+ return output;
+ }
+
+ private static string NormalizeImportMode(string? mode)
+ {
+ if (string.Equals(mode, AutoTriggerImportInput.ImportModes.Full, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(mode, "全量", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(mode, "replace", StringComparison.OrdinalIgnoreCase))
+ {
+ return AutoTriggerImportInput.ImportModes.Full;
+ }
+
+ return AutoTriggerImportInput.ImportModes.Incremental;
+ }
+
+ private static string BuildDuplicateKey(AutoTrigger entity) =>
+ $"{entity.RuleName}|{entity.Operation}|{entity.TriggerTiming}|{entity.TriggerCondition}";
+
///
/// 获取公式字段元数据。
///
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
index b5c9283..1c0ef56 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
@@ -6,6 +6,7 @@ using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.Itfx;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Messaging;
+using Oms.Application.Messaging.ParcelCreate;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Repositories;
@@ -23,6 +24,7 @@ public sealed class OrderBatchActionExecutor(
IObjectStorage fileStorage,
ISmallBagTrackOrderClient smallBagTrackOrderClient,
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
+ IOrderParcelCreateEventPublisher parcelCreateEventPublisher,
OrderDownstreamCredentialResolver downstreamCredentialResolver,
IItfxForwardClient itfxForwardClient,
OrderFileManager orderFileManager,
@@ -231,9 +233,31 @@ public sealed class OrderBatchActionExecutor(
if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null)
{
+ var previousTrackNo = order.TrackNo;
if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
{
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))
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
index b73fa91..3cac21b 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
@@ -1,5 +1,6 @@
using Fake.Application;
using Fake.DependencyInjection;
+using Fake.UnitOfWork;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
@@ -30,6 +31,7 @@ public sealed class OrderBatchJobCoordinator(
IObjectStorage fileStorage,
IOrderBatchJobPublisher publisher,
OrderBatchJobProcessor batchJobProcessor,
+ IUnitOfWorkManager unitOfWorkManager,
IOptions batchOptions,
ILogger 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 job = new OrderBatchJob
{
diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs
index 3cf553b..356230a 100644
--- a/src/Oms.Application/Services/Orders/OrderFileManager.cs
+++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs
@@ -3,6 +3,7 @@ using Fake.DependencyInjection;
using Fake.ObjectStorage;
using Microsoft.AspNetCore.Http;
using Oms.Application.Dtos.Orders;
+using Oms.Application.Services.Storage;
using Oms.Application.Storage;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
@@ -15,7 +16,8 @@ namespace Oms.Application.Services.Orders;
///
public sealed class OrderFileManager(
IOrderFileRepository orderFileRepository,
- IObjectStorage fileStorage) : IScopedDependency
+ IObjectStorage fileStorage,
+ IHttpClientFactory httpClientFactory) : IScopedDependency
{
private static readonly HashSet ImageExt =
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
@@ -205,6 +207,162 @@ public sealed class OrderFileManager(
return MapItem(saved);
}
+ ///
+ /// 下载远端文件转存自有对象存储后写入订单附件。
+ ///
+ public async Task 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> UploadAsync(
int orderId,
string fileType,
diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs b/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs
index 61120b2..a467162 100644
--- a/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs
@@ -1,5 +1,7 @@
+using System.Data;
using Fake.Application;
using Fake.Security.Claims;
+using Fake.UnitOfWork;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
@@ -21,6 +23,7 @@ public partial class OrderService
/// 创建订单批量异步任务并入队 RabbitMQ。
///
[HttpPost("/order/batch/jobs")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task CreateBatchJobAsync([FromBody] OrderBatchJobCreateInput input)
{
return BatchJobCoordinator.CreateAndEnqueueAsync(input, CurrentUser.GetId());
@@ -73,6 +76,7 @@ public partial class OrderService
/// 异步批量创建轨迹订单(替代前端逐条调用)。
///
[HttpPost("/order/batch/track-upsert")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchTrackUpsertAsync([FromBody] OrderBatchJobCreateInput input)
{
input.Action = OrderBatchActionCodes.TrackUpsertSmallBag;
@@ -94,6 +98,7 @@ public partial class OrderService
/// 异步批量预报下游订单。
///
[HttpPost("/order/batch/downstream-forward")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchDownstreamForwardAsync([FromBody] OrderBatchJobCreateInput input)
{
input.Action = OrderBatchActionCodes.DownstreamForwardCreateOne;
@@ -111,6 +116,7 @@ public partial class OrderService
string.IsNullOrWhiteSpace(authorization) ? null : authorization);
}
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
private Task EnqueueOrderBatchAsync(string action, OrderBatchJobCreateInput input)
{
input.Action = action;
@@ -121,6 +127,7 @@ public partial class OrderService
/// 异步批量支付订单。
///
[HttpPost("/order/batch/pay")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchPayAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.Pay, input);
@@ -128,6 +135,7 @@ public partial class OrderService
/// 异步批量预报订单。
///
[HttpPost("/order/batch/pre-declare")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchPreDeclareAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.PreDeclare, input);
@@ -135,6 +143,7 @@ public partial class OrderService
/// 异步批量拦截订单。
///
[HttpPost("/order/batch/intercept")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchInterceptAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.Intercept, input);
@@ -142,6 +151,7 @@ public partial class OrderService
/// 异步批量取消拦截。
///
[HttpPost("/order/batch/cancel-intercept")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchCancelInterceptAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.CancelIntercept, input);
@@ -149,6 +159,7 @@ public partial class OrderService
/// 异步批量删除订单。
///
[HttpPost("/order/batch/delete")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchDeleteAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.Delete, input);
@@ -156,6 +167,7 @@ public partial class OrderService
/// 异步批量打印面单。
///
[HttpPost("/order/batch/print")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchPrintAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.Print, input);
@@ -163,6 +175,7 @@ public partial class OrderService
/// 异步批量标记问题件。
///
[HttpPost("/order/batch/mark-problematic")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchMarkProblematicAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkProblematic, input);
@@ -170,6 +183,7 @@ public partial class OrderService
/// 异步批量重新排货。
///
[HttpPost("/order/batch/reallocate")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchReallocateAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.Reallocate, input);
@@ -177,6 +191,7 @@ public partial class OrderService
/// 异步批量标记已认领。
///
[HttpPost("/order/batch/mark-claimed")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchMarkClaimedAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkClaimed, input);
@@ -184,6 +199,7 @@ public partial class OrderService
/// 异步批量更新处理状态。
///
[HttpPost("/order/batch/handle-status-update")]
+ [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task BatchHandleStatusUpdateAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.HandleStatusUpdate, input);
diff --git a/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs b/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs
index b8eb919..37934fc 100644
--- a/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.DownstreamForward.cs
@@ -1,5 +1,6 @@
using Fake.Application;
using Fake.Domain.Exceptions;
+using Fake.UnitOfWork;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.Itfx;
@@ -36,6 +37,7 @@ public partial class OrderService
/// 按订单 Id 组装 createOrderOne 并转发至 itfx 下游。
///
[HttpPost("/order/downstream-forward-create-one")]
+ [UnitOfWork(IsTransactional = true)]
public async Task DownstreamForwardCreateOneAsync([FromBody] OrderActionInput input)
{
var (order, body) = await LoadDownstreamCreateOneContextAsync(input.Id);
@@ -55,9 +57,11 @@ public partial class OrderService
&& ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null)
{
+ var previousTrackNo = order.TrackNo;
if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
{
await OrderRepository.UpdateAsync(order);
+ await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo);
}
if (!string.IsNullOrWhiteSpace(data.LabelUrl))
@@ -82,6 +86,74 @@ public partial class OrderService
};
}
+ ///
+ /// 按订单 Id 组装 getFile 并转发至 itfx 下游;成功时转存文件并回写单号。
+ ///
+ [HttpPost("/order/downstream-forward-get-file")]
+ public async Task 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() : 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(
int orderId)
{
diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
index 076e3f3..6b43287 100644
--- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
@@ -48,6 +48,7 @@ public partial class OrderService
order.ClientNo = newClientNo;
}
+ var previousTrackNo = order.TrackNo;
if (input.TrackNo != null)
{
order.TrackNo = string.IsNullOrWhiteSpace(input.TrackNo) ? null : input.TrackNo.Trim();
@@ -155,6 +156,11 @@ public partial class OrderService
afterSnap,
remark);
+ if (input.TrackNo != null)
+ {
+ await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo);
+ }
+
return (await MapOrderOutputsAsync([order])).First();
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index e2e7fa6..9d3b15a 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -3201,6 +3201,25 @@ public partial class OrderService
await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
}
+ ///
+ /// 跟踪号相对旧值有变更且新值非空时,再创建/更新一次轨迹订单。
+ ///
+ 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)
{
if (string.IsNullOrWhiteSpace(order.ServiceNo))
diff --git a/src/Oms.Application/Shared/AutoTriggerImportHelper.cs b/src/Oms.Application/Shared/AutoTriggerImportHelper.cs
new file mode 100644
index 0000000..7c7c66b
--- /dev/null
+++ b/src/Oms.Application/Shared/AutoTriggerImportHelper.cs
@@ -0,0 +1,290 @@
+using MiniExcelLibs;
+using Oms.Application.Services.Orders.Issues;
+using Oms.Domain.ChannelsAggregate.Entities;
+using Oms.Domain.Shared.Enums;
+
+namespace Oms.Application.Shared;
+
+///
+/// 自动触发规则 Excel 模板与导入解析。
+///
+public static class AutoTriggerImportHelper
+{
+ public const string TemplateSheetName = "导入模板";
+ public const string EnumSheetName = "枚举值";
+
+ private static readonly Dictionary HeaderAliases = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["规则名称"] = nameof(AutoTrigger.RuleName),
+ ["RuleName"] = nameof(AutoTrigger.RuleName),
+ ["ruleName"] = nameof(AutoTrigger.RuleName),
+ ["优先级"] = nameof(AutoTrigger.Priority),
+ ["Priority"] = nameof(AutoTrigger.Priority),
+ ["priority"] = nameof(AutoTrigger.Priority),
+ ["状态"] = nameof(AutoTrigger.Status),
+ ["Status"] = nameof(AutoTrigger.Status),
+ ["status"] = nameof(AutoTrigger.Status),
+ ["操作"] = nameof(AutoTrigger.Operation),
+ ["Operation"] = nameof(AutoTrigger.Operation),
+ ["operation"] = nameof(AutoTrigger.Operation),
+ ["触发时机"] = nameof(AutoTrigger.TriggerTiming),
+ ["TriggerTiming"] = nameof(AutoTrigger.TriggerTiming),
+ ["triggerTiming"] = nameof(AutoTrigger.TriggerTiming),
+ ["触发条件"] = nameof(AutoTrigger.TriggerCondition),
+ ["TriggerCondition"] = nameof(AutoTrigger.TriggerCondition),
+ ["triggerCondition"] = nameof(AutoTrigger.TriggerCondition),
+ ["提示语"] = nameof(AutoTrigger.PromptMessage),
+ ["提示信息"] = nameof(AutoTrigger.PromptMessage),
+ ["PromptMessage"] = nameof(AutoTrigger.PromptMessage),
+ ["promptMessage"] = nameof(AutoTrigger.PromptMessage),
+ ["通知人"] = nameof(AutoTrigger.Notifier),
+ ["通知对象"] = nameof(AutoTrigger.Notifier),
+ ["Notifier"] = nameof(AutoTrigger.Notifier),
+ ["notifier"] = nameof(AutoTrigger.Notifier),
+ };
+
+ public static async Task BuildTemplateBytesAsync()
+ {
+ var templateRows = new List>
+ {
+ new()
+ {
+ ["规则名称"] = "英国超尺寸拦截示例",
+ ["优先级"] = 100,
+ ["状态"] = "启用",
+ ["操作"] = "拦截",
+ ["触发时机"] = "下单",
+ ["触发条件"] = "{\n \"expression\": \"收件国家 == \\\"GB\\\"\"\n}",
+ ["提示语"] = "超尺寸,禁止下单",
+ ["通知人"] = ""
+ }
+ };
+
+ var enumRows = new List>
+ {
+ new() { ["枚举类型"] = "状态", ["编码"] = "2", ["名称"] = "启用" },
+ new() { ["枚举类型"] = "状态", ["编码"] = "1", ["名称"] = "禁用" },
+ new() { ["枚举类型"] = "操作", ["编码"] = "intercept", ["名称"] = "拦截" },
+ new() { ["枚举类型"] = "操作", ["编码"] = "warn", ["名称"] = "警告" },
+ new() { ["枚举类型"] = "操作", ["编码"] = "problem", ["名称"] = "问题件" },
+ new() { ["枚举类型"] = "触发时机", ["编码"] = "order_create", ["名称"] = "下单" },
+ new() { ["枚举类型"] = "触发时机", ["编码"] = "inbound", ["名称"] = "入库" },
+ new() { ["枚举类型"] = "触发时机", ["编码"] = "outbound", ["名称"] = "出库" },
+ };
+
+ var sheets = new Dictionary
+ {
+ [TemplateSheetName] = templateRows,
+ [EnumSheetName] = enumRows
+ };
+
+ await using var stream = new MemoryStream();
+ await MiniExcel.SaveAsAsync(stream, sheets);
+ return stream.ToArray();
+ }
+
+ public static string TemplateFileName => "自动触发规则导入模板.xlsx";
+
+ internal static string? ResolveImportSheetName(IEnumerable sheetNames)
+ {
+ var names = sheetNames.Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
+ if (names.Count == 0)
+ {
+ return null;
+ }
+
+ return names.FirstOrDefault(x =>
+ string.Equals(x, TemplateSheetName, StringComparison.OrdinalIgnoreCase))
+ ?? names.FirstOrDefault(x =>
+ !string.Equals(x, EnumSheetName, StringComparison.OrdinalIgnoreCase))
+ ?? names[0];
+ }
+
+ public static async Task> ParseImportRowsAsync(Stream fileStream)
+ {
+ if (fileStream.CanSeek)
+ {
+ fileStream.Position = 0;
+ }
+
+ var sheetNames = MiniExcel.GetSheetNames(fileStream);
+ var sheetName = ResolveImportSheetName(sheetNames);
+
+ if (fileStream.CanSeek)
+ {
+ fileStream.Position = 0;
+ }
+
+ var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true, sheetName: sheetName);
+ var result = new List<(int RowNumber, AutoTrigger Entity)>();
+ var rowNumber = 1;
+
+ foreach (var row in rows)
+ {
+ rowNumber++;
+ if (row is not IDictionary rowDict)
+ {
+ continue;
+ }
+
+ if (IsEmptyRow(rowDict))
+ {
+ continue;
+ }
+
+ result.Add((rowNumber, ParseRow(rowDict)));
+ }
+
+ return result;
+ }
+
+ public static void ValidateEntity(AutoTrigger entity)
+ {
+ if (string.IsNullOrWhiteSpace(entity.RuleName))
+ {
+ throw new InvalidOperationException("规则名称不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.TriggerCondition))
+ {
+ throw new InvalidOperationException("触发条件不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.Operation))
+ {
+ throw new InvalidOperationException("操作不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.TriggerTiming))
+ {
+ throw new InvalidOperationException("触发时机不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.PromptMessage))
+ {
+ entity.PromptMessage = entity.RuleName;
+ }
+
+ if (entity.Priority <= 0)
+ {
+ entity.Priority = 100;
+ }
+ }
+
+ private static AutoTrigger ParseRow(IDictionary rowDict)
+ {
+ var mapped = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (header, value) in rowDict)
+ {
+ if (string.IsNullOrWhiteSpace(header))
+ {
+ continue;
+ }
+
+ if (!HeaderAliases.TryGetValue(header.Trim(), out var field))
+ {
+ continue;
+ }
+
+ mapped[field] = NormalizeCell(value);
+ }
+
+ var operation = NormalizeOperation(mapped.GetValueOrDefault(nameof(AutoTrigger.Operation)));
+ var timing = AutoTriggerTimingCodes.ToCanonicalLabel(
+ mapped.GetValueOrDefault(nameof(AutoTrigger.TriggerTiming)));
+ var status = NormalizeStatus(mapped.GetValueOrDefault(nameof(AutoTrigger.Status)));
+ var priorityText = mapped.GetValueOrDefault(nameof(AutoTrigger.Priority));
+ var priority = int.TryParse(priorityText, out var p) && p > 0 ? p : 100;
+
+ return new AutoTrigger
+ {
+ RuleName = mapped.GetValueOrDefault(nameof(AutoTrigger.RuleName))?.Trim(),
+ Priority = priority,
+ Status = status,
+ Operation = operation,
+ TriggerTiming = timing,
+ TriggerCondition = mapped.GetValueOrDefault(nameof(AutoTrigger.TriggerCondition)),
+ PromptMessage = mapped.GetValueOrDefault(nameof(AutoTrigger.PromptMessage))?.Trim(),
+ Notifier = mapped.GetValueOrDefault(nameof(AutoTrigger.Notifier))?.Trim()
+ };
+ }
+
+ private static string NormalizeOperation(string? raw)
+ {
+ var text = (raw ?? string.Empty).Trim();
+ if (string.IsNullOrEmpty(text))
+ {
+ return string.Empty;
+ }
+
+ if (ContainsAny(text, "拦截", "intercept", "block"))
+ {
+ return "拦截";
+ }
+
+ if (ContainsAny(text, "警告", "warn", "warning"))
+ {
+ return "警告";
+ }
+
+ if (ContainsAny(text, "问题件", "problem", "issue"))
+ {
+ return "问题件";
+ }
+
+ return text;
+ }
+
+ private static EnableStatus NormalizeStatus(string? raw)
+ {
+ var text = (raw ?? string.Empty).Trim();
+ if (string.IsNullOrEmpty(text))
+ {
+ return EnableStatus.Enabled;
+ }
+
+ if (text is "1" or "禁用" or "停用" or "disabled" or "Disabled" or "false" or "False" or "0")
+ {
+ return EnableStatus.Disabled;
+ }
+
+ return EnableStatus.Enabled;
+ }
+
+ private static bool ContainsAny(string text, params string[] keywords)
+ {
+ foreach (var keyword in keywords)
+ {
+ if (text.Contains(keyword, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static string? NormalizeCell(object? value)
+ {
+ if (value == null || value is DBNull)
+ {
+ return null;
+ }
+
+ var text = Convert.ToString(value)?.Trim();
+ return string.IsNullOrWhiteSpace(text) ? null : text;
+ }
+
+ private static bool IsEmptyRow(IDictionary rowDict)
+ {
+ foreach (var value in rowDict.Values)
+ {
+ if (!string.IsNullOrWhiteSpace(NormalizeCell(value)))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
index 37ecb2f..c48e471 100644
--- a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
+++ b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
@@ -1,6 +1,7 @@
using FluentAssertions;
using Fake.Application;
using Fake.ObjectStorage;
+using Fake.UnitOfWork;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
@@ -36,6 +37,8 @@ public class OrderBatchJobCoordinatorGovernanceTests
actionExecutor.Object,
batchOptions,
NullLogger.Instance);
+ var unitOfWorkManager = new Mock(MockBehavior.Loose);
+ unitOfWorkManager.SetupGet(x => x.Current).Returns((IUnitOfWork?)null);
_coordinator = new OrderBatchJobCoordinator(
_batchJobRepository.Object,
_orderRepository.Object,
@@ -44,6 +47,7 @@ public class OrderBatchJobCoordinatorGovernanceTests
_fileStorage.Object,
_publisher.Object,
processor,
+ unitOfWorkManager.Object,
batchOptions,
NullLogger.Instance);
}
diff --git a/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs b/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs
index 4be8521..df70e79 100644
--- a/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs
+++ b/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs
@@ -20,7 +20,7 @@ public class SmallBagTrackUpsertRequestBuilderTests
}
[Fact]
- public void ResolveTrackNo_FallsBackToServiceNoWhenTrackNoEmpty()
+ public void ResolveTrackNo_EmptyWhenTrackNoMissing()
{
var order = new Order
{
@@ -28,7 +28,7 @@ public class SmallBagTrackUpsertRequestBuilderTests
TrackNo = null
};
- Assert.Equal("IDE2607221859123456", SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order));
+ Assert.Equal(string.Empty, SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order));
}
[Fact]