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]