Merge remote-tracking branch 'prod/test' into test

This commit is contained in:
xiaolipro 2026-10-13 10:54:45 +08:00
commit cf77ffe943
24 changed files with 1089 additions and 35 deletions

View File

@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Http;
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 自动触发规则 Excel 导入入参。
/// </summary>
public class AutoTriggerImportInput
{
/// <summary>
/// 导入模式:incremental=增量;full=全量(先清空再导入)。
/// </summary>
public string ImportMode { get; set; } = ImportModes.Incremental;
/// <summary>
/// Excel 文件。
/// </summary>
public IFormFile File { get; set; } = null!;
public static class ImportModes
{
public const string Incremental = "incremental";
public const string Full = "full";
}
}

View File

@ -0,0 +1,25 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 自动触发规则 Excel 导入结果。
/// </summary>
public class AutoTriggerImportOutput
{
public int SuccessCount { get; set; }
public int SkipCount { get; set; }
public int FailCount { get; set; }
public List<AutoTriggerImportFailRow> Failures { get; set; } = [];
}
/// <summary>
/// 导入失败行。
/// </summary>
public class AutoTriggerImportFailRow
{
public int RowNumber { get; set; }
public string Reason { get; set; } = string.Empty;
}

View File

@ -0,0 +1,27 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// itfx getFile(ForwardWithSubscribe)标准请求体。
/// </summary>
public class ItfxGetFileBody
{
/// <summary>
/// 客户单号。
/// </summary>
public string? ClientNo { get; set; }
/// <summary>
/// 跟踪号。
/// </summary>
public string? TrackNo { get; set; }
/// <summary>
/// 服务商单号。
/// </summary>
public string? ServiceNo { get; set; }
/// <summary>
/// 文件类型:下游面单=21,末端面单=31,POD=180。
/// </summary>
public int Type { get; set; }
}

View File

@ -1,5 +1,3 @@
using Oms.Application.Dtos.Orders;
namespace Oms.Application.Dtos.Orders; namespace Oms.Application.Dtos.Orders;
/// <summary> /// <summary>
@ -43,3 +41,56 @@ public class OrderDownstreamForwardResult
/// </summary> /// </summary>
public string? TrackNo { get; set; } public string? TrackNo { get; set; }
} }
/// <summary>
/// 下游 getFile 入参。
/// </summary>
public class OrderDownstreamGetFileInput
{
/// <summary>
/// 订单 Id。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 文件类型:下游面单=21,末端面单=31,POD=180。
/// </summary>
public int Type { get; set; }
}
/// <summary>
/// 下游 getFile 转发结果。
/// </summary>
public class OrderDownstreamGetFileResult
{
public bool Success { get; set; }
public string SupplierCode { get; set; } = string.Empty;
public int Type { get; set; }
public string? FileType { get; set; }
public string Url { get; set; } = string.Empty;
public string RequestJson { get; set; } = string.Empty;
public int? StatusCode { get; set; }
public string ResponseBody { get; set; } = string.Empty;
/// <summary>
/// 转存到自有 COS 后的公开地址。
/// </summary>
public string? StoredFileUrl { get; set; }
/// <summary>
/// 回写后的服务商单号(Data.serviceNo)。
/// </summary>
public string? DownstreamServiceNo { get; set; }
/// <summary>
/// 回写后的跟踪号(Data.trackNo)。
/// </summary>
public string? TrackNo { get; set; }
}

View File

@ -0,0 +1,171 @@
using System.Text.Json;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Integrations.Itfx;
/// <summary>
/// 解析 itfx getFile 成功响应中的 Data,并回写订单单号。
/// </summary>
internal static class ItfxGetFileDataParser
{
public const string StandardCode = "getFile";
public const int TypeDownstreamLabel = 21;
public const int TypeLastMileLabel = 31;
public const int TypePod = 180;
public sealed class GetFileData
{
public string? ServiceNo { get; init; }
public string? TrackNo { get; init; }
public string? ClientNo { get; init; }
public string? Url { get; init; }
public int? Type { get; init; }
}
public static bool TryResolveFileType(int type, out string fileType)
{
fileType = type switch
{
TypeDownstreamLabel => OrderFileTypeCodes.DownstreamLabel,
TypeLastMileLabel => OrderFileTypeCodes.LastMileLabel,
TypePod => OrderFileTypeCodes.Pod,
_ => string.Empty
};
return !string.IsNullOrEmpty(fileType);
}
public static bool TryParse(string? responseBody, out GetFileData? data)
{
data = null;
if (string.IsNullOrWhiteSpace(responseBody))
{
return false;
}
try
{
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return false;
}
if (!TryGetPropertyIgnoreCase(root, out var dataElement, "data", "Data")
|| dataElement.ValueKind != JsonValueKind.Object)
{
return false;
}
data = new GetFileData
{
ServiceNo = ReadString(dataElement, "serviceNo", "ServiceNo"),
TrackNo = ReadString(dataElement, "trackNo", "TrackNo"),
ClientNo = ReadString(dataElement, "clientNo", "ClientNo"),
Url = ReadString(dataElement, "url", "Url"),
Type = ReadInt(dataElement, "type", "Type"),
};
return !string.IsNullOrWhiteSpace(data.ServiceNo)
|| !string.IsNullOrWhiteSpace(data.TrackNo)
|| !string.IsNullOrWhiteSpace(data.ClientNo)
|| !string.IsNullOrWhiteSpace(data.Url);
}
catch
{
return false;
}
}
/// <summary>
/// 服务商单号 ← Data.serviceNo,跟踪号 ← Data.trackNo;空值不覆盖。
/// </summary>
public static bool ApplyToOrder(Order order, GetFileData data)
{
var changed = false;
if (!string.IsNullOrWhiteSpace(data.ServiceNo))
{
var value = data.ServiceNo.Trim();
if (!string.Equals(order.DownstreamServiceNo, value, StringComparison.Ordinal))
{
order.DownstreamServiceNo = value;
changed = true;
}
}
if (!string.IsNullOrWhiteSpace(data.TrackNo))
{
var value = data.TrackNo.Trim();
if (!string.Equals(order.TrackNo, value, StringComparison.Ordinal))
{
order.TrackNo = value;
changed = true;
}
}
return changed;
}
private static bool TryGetPropertyIgnoreCase(JsonElement root, out JsonElement value, params string[] names)
{
foreach (var name in names)
{
if (root.TryGetProperty(name, out value))
{
return true;
}
}
foreach (var prop in root.EnumerateObject())
{
foreach (var name in names)
{
if (prop.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
}
value = default;
return false;
}
private static string? ReadString(JsonElement root, params string[] names)
{
if (!TryGetPropertyIgnoreCase(root, out var prop, names))
{
return null;
}
return prop.ValueKind switch
{
JsonValueKind.String => prop.GetString()?.Trim(),
JsonValueKind.Number => prop.GetRawText(),
JsonValueKind.Null => null,
_ => null
};
}
private static int? ReadInt(JsonElement root, params string[] names)
{
if (!TryGetPropertyIgnoreCase(root, out var prop, names))
{
return null;
}
return prop.ValueKind switch
{
JsonValueKind.Number when prop.TryGetInt32(out var n) => n,
JsonValueKind.String when int.TryParse(prop.GetString(), out var n) => n,
_ => null
};
}
}

View File

@ -67,16 +67,11 @@ internal sealed class SmallBagTrackUpsertRequestBuilder(
}; };
} }
internal static string ResolveTrackNo(Order order) /// <summary>
{ /// 仅取运单跟踪号;无值则空串,不回落运单号/客户单号等。
var trackNo = order.TrackNo?.Trim(); /// </summary>
if (!string.IsNullOrWhiteSpace(trackNo)) internal static string ResolveTrackNo(Order order) =>
{ order.TrackNo?.Trim() ?? string.Empty;
return trackNo;
}
return order.ServiceNo?.Trim() ?? string.Empty;
}
internal static string? PickSupplierCodeFromProviders( internal static string? PickSupplierCodeFromProviders(
IEnumerable<SalesProductAllocationProviderSnapshotDto> providers, IEnumerable<SalesProductAllocationProviderSnapshotDto> providers,

View File

@ -1,3 +1,4 @@
using System.Data;
using Fake.EntityFrameworkCore; using Fake.EntityFrameworkCore;
using Fake.EventBus.Distributed; using Fake.EventBus.Distributed;
using Fake.EventBus.IntegrationEventLog; using Fake.EventBus.IntegrationEventLog;
@ -9,6 +10,7 @@ namespace Oms.Application.Messaging;
/// <summary> /// <summary>
/// 可靠发布 IntegrationEvent:事务 UoW 内写入 Outbox(由 Fake.EventBus.IntegrationEventLog 投递)。 /// 可靠发布 IntegrationEvent:事务 UoW 内写入 Outbox(由 Fake.EventBus.IntegrationEventLog 投递)。
/// SqlSugar 与 EF 默认各持连接;若环境事务已由 SqlSugar 占用,则改用 RequiresNew 短事务只写 Outbox。
/// </summary> /// </summary>
internal static class OmsIntegrationEventPublisher internal static class OmsIntegrationEventPublisher
{ {
@ -21,6 +23,9 @@ internal static class OmsIntegrationEventPublisher
{ {
if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager)) if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager))
{ {
try
{
await EnsureOutboxDbContextAsync(unitOfWorkManager);
await eventBus.PublishAsync(@event, cancellationToken); await eventBus.PublishAsync(@event, cancellationToken);
logger.LogDebug( logger.LogDebug(
"Outbox enqueued {EventName} Id={EventId} (ambient TX)", "Outbox enqueued {EventName} Id={EventId} (ambient TX)",
@ -28,17 +33,31 @@ internal static class OmsIntegrationEventPublisher
@event.Id); @event.Id);
return; return;
} }
catch (Exception ex) when (IsTxConnectionMismatch(ex))
// 无环境事务时开启短事务只写 Outbox,并告警:业务落库与事件非原子。 {
// 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( logger.LogWarning(
"Publish {EventName} Id={EventId} without ambient transactional UoW; opening short outbox TX", "Publish {EventName} Id={EventId} without ambient transactional UoW; opening short outbox TX",
@event.GetType().Name, @event.GetType().Name,
@event.Id); @event.Id);
}
using var uow = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); using var uow = unitOfWorkManager.Begin(new UnitOfWorkAttribute
// 先打开 Outbox DbContext 以产生可征用的 DbTransaction {
var dbProvider = uow.ServiceProvider.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>(); IsTransactional = true,
await dbProvider.GetDbContextAsync(); RequiresNew = true,
IsolationLevel = IsolationLevel.ReadCommitted
});
await EnsureOutboxDbContextAsync(unitOfWorkManager);
await eventBus.PublishAsync(@event, cancellationToken); await eventBus.PublishAsync(@event, cancellationToken);
await uow.CompleteAsync(cancellationToken); await uow.CompleteAsync(cancellationToken);
} }
@ -65,4 +84,30 @@ internal static class OmsIntegrationEventPublisher
return false; return false;
} }
} }
/// <summary>
/// 业务写库前先打开 Outbox DbContext,尽量让 EF 先占住环境事务连接,供 SqlSugar 征用。
/// </summary>
public static Task EnsureOutboxDbContextAsync(IUnitOfWorkManager unitOfWorkManager)
{
var current = unitOfWorkManager.Current
?? throw new InvalidOperationException("No ambient unit of work");
var dbProvider = current.ServiceProvider
.GetRequiredService<IEfDbContextProvider<IntegrationEventLogContext>>();
return dbProvider.GetDbContextAsync();
}
private static bool IsTxConnectionMismatch(Exception ex)
{
for (var e = ex; e != null; e = e.InnerException!)
{
if (e.Message.Contains("not associated with the current connection", StringComparison.OrdinalIgnoreCase)
|| e.Message.Contains("associated with the current connection", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
} }

View File

@ -1,6 +1,7 @@
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Fake.EventBus; using Fake.EventBus;
using Fake.UnitOfWork; using Fake.UnitOfWork;
using Oms.Application.Messaging;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders.Issues; using Oms.Application.Services.Orders.Issues;
@ -26,7 +27,7 @@ public sealed class OrderAutoTriggerFireEventHandler(
return; return;
} }
using var unitOfWork = unitOfWorkManager.Begin(); using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
try try
{ {
await autoTriggerRuntimeService.FireByOrderIdAsync( await autoTriggerRuntimeService.FireByOrderIdAsync(

View File

@ -1,6 +1,7 @@
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Fake.EventBus; using Fake.EventBus;
using Fake.UnitOfWork; using Fake.UnitOfWork;
using Oms.Application.Messaging;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Integrations.SmallBagTrack;
@ -24,7 +25,7 @@ public sealed class OrderLifecycleTrackAppendEventHandler(
return; return;
} }
using var unitOfWork = unitOfWorkManager.Begin(); using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
try try
{ {
await trackClient.AppendLifecycleTrackAsync( await trackClient.AppendLifecycleTrackAsync(

View File

@ -1,6 +1,7 @@
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Fake.EventBus; using Fake.EventBus;
using Fake.UnitOfWork; using Fake.UnitOfWork;
using Oms.Application.Messaging;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders; using Oms.Application.Services.Orders;
using Oms.Domain.OrdersAggregate.Repositories; using Oms.Domain.OrdersAggregate.Repositories;
@ -21,7 +22,7 @@ public sealed class OrderParcelCustoms9610UpsertEventHandler(
OrderParcelCustoms9610UpsertIntegrationEvent @event, OrderParcelCustoms9610UpsertIntegrationEvent @event,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using var unitOfWork = unitOfWorkManager.Begin(); using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken); var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken);
if (order == null) if (order == null)
{ {

View File

@ -1,6 +1,7 @@
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Fake.EventBus; using Fake.EventBus;
using Fake.UnitOfWork; using Fake.UnitOfWork;
using Oms.Application.Messaging;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders; using Oms.Application.Services.Orders;
@ -20,7 +21,7 @@ public sealed class OrderParcelLabelGenerateEventHandler(
OrderParcelLabelGenerateIntegrationEvent @event, OrderParcelLabelGenerateIntegrationEvent @event,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using var unitOfWork = unitOfWorkManager.Begin(); using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>(); var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
var result = await orderService.GenerateLabelPdfForBatchAsync(@event.OrderId, cancellationToken); var result = await orderService.GenerateLabelPdfForBatchAsync(@event.OrderId, cancellationToken);

View File

@ -1,6 +1,7 @@
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Fake.EventBus; using Fake.EventBus;
using Fake.UnitOfWork; using Fake.UnitOfWork;
using Oms.Application.Messaging;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders; using Oms.Application.Services.Orders;
@ -21,7 +22,7 @@ public sealed class OrderParcelPickupAttachEventHandler(
OrderParcelPickupAttachIntegrationEvent @event, OrderParcelPickupAttachIntegrationEvent @event,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using var unitOfWork = unitOfWorkManager.Begin(); using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
try try
{ {
await pickupOrderService.CreateForParcelInternalAsync(new PickupOrderCreateInput await pickupOrderService.CreateForParcelInternalAsync(new PickupOrderCreateInput

View File

@ -2,6 +2,7 @@ using Fake.Application;
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Fake.EventBus; using Fake.EventBus;
using Fake.UnitOfWork; using Fake.UnitOfWork;
using Oms.Application.Messaging;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Integrations.SmallBagTrack;
@ -24,7 +25,7 @@ public sealed class OrderParcelTrackUpsertEventHandler(
OrderParcelTrackUpsertIntegrationEvent @event, OrderParcelTrackUpsertIntegrationEvent @event,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using var unitOfWork = unitOfWorkManager.Begin(); using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
var request = await ResolveRequestAsync(@event, cancellationToken); var request = await ResolveRequestAsync(@event, cancellationToken);
request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess; request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess;

View File

@ -38,6 +38,118 @@ public class AutoTriggerService : ApplicationService
return new OmsPageResult<AutoTriggerOutput> { Items = items, Total = total }; return new OmsPageResult<AutoTriggerOutput> { Items = items, Total = total };
} }
/// <summary>
/// 下载自动触发规则导入模板。
/// </summary>
[HttpGet("/channel/auto-trigger/import-template")]
public async Task<FileContentResult> DownloadImportTemplateAsync()
{
var bytes = await AutoTriggerImportHelper.BuildTemplateBytesAsync();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = AutoTriggerImportHelper.TemplateFileName
};
}
/// <summary>
/// 导入自动触发规则(Excel)。
/// </summary>
[HttpPost("/channel/auto-trigger/import")]
public async Task<AutoTriggerImportOutput> 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<string>(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}";
/// <summary> /// <summary>
/// 获取公式字段元数据。 /// 获取公式字段元数据。
/// </summary> /// </summary>

View File

@ -6,6 +6,7 @@ using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.Itfx; using Oms.Application.Integrations.Itfx;
using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Messaging; using Oms.Application.Messaging;
using Oms.Application.Messaging.ParcelCreate;
using Oms.Application.Shared; using Oms.Application.Shared;
using Oms.Application.Storage; using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.ChannelsAggregate.Repositories;
@ -23,6 +24,7 @@ public sealed class OrderBatchActionExecutor(
IObjectStorage fileStorage, IObjectStorage fileStorage,
ISmallBagTrackOrderClient smallBagTrackOrderClient, ISmallBagTrackOrderClient smallBagTrackOrderClient,
ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder, ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder,
IOrderParcelCreateEventPublisher parcelCreateEventPublisher,
OrderDownstreamCredentialResolver downstreamCredentialResolver, OrderDownstreamCredentialResolver downstreamCredentialResolver,
IItfxForwardClient itfxForwardClient, IItfxForwardClient itfxForwardClient,
OrderFileManager orderFileManager, OrderFileManager orderFileManager,
@ -231,9 +233,31 @@ public sealed class OrderBatchActionExecutor(
if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data) if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null) && data != null)
{ {
var previousTrackNo = order.TrackNo;
if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data)) if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
{ {
await orderRepository.UpdateAsync(order, cancellationToken); await orderRepository.UpdateAsync(order, cancellationToken);
var nextTrackNo = order.TrackNo?.Trim() ?? string.Empty;
var prevTrackNo = previousTrackNo?.Trim() ?? string.Empty;
if (!string.IsNullOrWhiteSpace(nextTrackNo)
&& !string.Equals(nextTrackNo, prevTrackNo, StringComparison.Ordinal))
{
var trackRequest = await smallBagTrackUpsertRequestBuilder.BuildAsync(
order,
cancellationToken: cancellationToken);
trackRequest.AppendInitialTrackOnSuccess = true;
if (!string.IsNullOrWhiteSpace(authorization))
{
trackRequest.Authorization = authorization.Trim();
}
await parcelCreateEventPublisher.PublishTrackUpsertAsync(
order,
trackRequest,
customerId: order.CustomerId,
cancellationToken);
}
} }
if (!string.IsNullOrWhiteSpace(data.LabelUrl)) if (!string.IsNullOrWhiteSpace(data.LabelUrl))

View File

@ -1,5 +1,6 @@
using Fake.Application; using Fake.Application;
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -30,6 +31,7 @@ public sealed class OrderBatchJobCoordinator(
IObjectStorage fileStorage, IObjectStorage fileStorage,
IOrderBatchJobPublisher publisher, IOrderBatchJobPublisher publisher,
OrderBatchJobProcessor batchJobProcessor, OrderBatchJobProcessor batchJobProcessor,
IUnitOfWorkManager unitOfWorkManager,
IOptions<OrderBatchRabbitMqOptions> batchOptions, IOptions<OrderBatchRabbitMqOptions> batchOptions,
ILogger<OrderBatchJobCoordinator> logger) : IScopedDependency ILogger<OrderBatchJobCoordinator> logger) : IScopedDependency
{ {
@ -76,6 +78,12 @@ public sealed class OrderBatchJobCoordinator(
} }
} }
// 先打开 EF Outbox 上下文,尽量让环境事务由 EF 占连接,避免 SqlSugar 先开事务后 Outbox 征用失败。
if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager))
{
await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager);
}
var now = DateTime.Now; var now = DateTime.Now;
var job = new OrderBatchJob var job = new OrderBatchJob
{ {

View File

@ -3,6 +3,7 @@ using Fake.DependencyInjection;
using Fake.ObjectStorage; using Fake.ObjectStorage;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Storage;
using Oms.Application.Storage; using Oms.Application.Storage;
using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Entities;
@ -15,7 +16,8 @@ namespace Oms.Application.Services.Orders;
/// </summary> /// </summary>
public sealed class OrderFileManager( public sealed class OrderFileManager(
IOrderFileRepository orderFileRepository, IOrderFileRepository orderFileRepository,
IObjectStorage fileStorage) : IScopedDependency IObjectStorage fileStorage,
IHttpClientFactory httpClientFactory) : IScopedDependency
{ {
private static readonly HashSet<string> ImageExt = private static readonly HashSet<string> ImageExt =
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" }; new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
@ -205,6 +207,162 @@ public sealed class OrderFileManager(
return MapItem(saved); return MapItem(saved);
} }
/// <summary>
/// 下载远端文件转存自有对象存储后写入订单附件。
/// </summary>
public async Task<OrderFileItemOutput> AddOrReplaceFromRemoteUrlAsync(
int orderId,
string fileType,
string remoteUrl,
int operatorUserId,
bool replaceExisting = true,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(remoteUrl))
{
throw new BusinessException("下游文件地址为空");
}
if (!OrderFileTypeCodes.IsValid(fileType))
{
throw new BusinessException("不支持的附件类型");
}
var type = OrderFileTypeCodes.Normalize(fileType);
var sourceUrl = remoteUrl.Trim();
var stored = await DownloadAndStoreRemoteAsync(orderId, type, sourceUrl, cancellationToken);
if (replaceExisting)
{
await orderFileRepository.SoftDeleteByOrderAndTypeAsync(orderId, type, cancellationToken);
}
else
{
var existing = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
var dup = existing.FirstOrDefault(x =>
string.Equals(x.FileUrl, stored.Key, StringComparison.OrdinalIgnoreCase)
|| string.Equals(x.FileUrl, sourceUrl, StringComparison.OrdinalIgnoreCase));
if (dup != null)
{
return MapItem(dup);
}
if (existing.Count >= OrderFileTypeCodes.GetMaxCount(type))
{
var oldest = existing.OrderBy(x => x.Id).First();
oldest.IsDeleted = true;
oldest.UpdateTime = DateTime.Now;
await orderFileRepository.UpdateAsync(oldest, cancellationToken);
}
}
var existingAfter = replaceExisting
? []
: await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
var entity = new OrderFile
{
OrderId = orderId,
FileType = type,
FileName = stored.FileName,
FileUrl = stored.Key,
ContentType = stored.ContentType,
FileSize = stored.Size,
Sort = existingAfter.Count,
Source = OrderFileSourceCodes.Downstream,
Remark = sourceUrl.Length > 500 ? sourceUrl[..500] : sourceUrl,
CreateUserId = operatorUserId,
CreateTime = DateTime.Now
};
var saved = await orderFileRepository.InsertAsync(entity, cancellationToken);
return MapItem(saved);
}
private async Task<(string Key, string? FileName, string? ContentType, long Size)> DownloadAndStoreRemoteAsync(
int orderId,
string fileType,
string remoteUrl,
CancellationToken cancellationToken)
{
byte[] bytes;
string? contentType;
try
{
var client = httpClientFactory.CreateClient(FileStorageService.UrlFetchClientName);
using var request = new HttpRequestMessage(HttpMethod.Get, remoteUrl);
request.Headers.TryAddWithoutValidation("Accept", "*/*");
using var response = await client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new BusinessException($"下载下游文件失败:HTTP {(int)response.StatusCode}");
}
bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
contentType = response.Content.Headers.ContentType?.MediaType;
}
catch (BusinessException)
{
throw;
}
catch (Exception ex)
{
throw new BusinessException($"下载下游文件失败:{ex.Message}");
}
if (bytes.Length == 0)
{
throw new BusinessException("下游文件内容为空");
}
if (bytes.Length > MaxPdfBytes)
{
throw new BusinessException("下游文件不能超过 10MB");
}
var ext = GuessExtension(remoteUrl, contentType);
var fileName = TryFileNameFromUrl(remoteUrl) ?? $"{fileType}{ext}";
var storedName = $"{Guid.NewGuid():N}{ext}";
var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}";
await using var stream = new MemoryStream(bytes);
var key = await fileStorage.SaveObjectAsync(stream, relativeKey, contentType, cancellationToken);
return (key, fileName, contentType, bytes.Length);
}
private static string GuessExtension(string url, string? contentType)
{
var fromUrl = Path.GetExtension(
(url.Contains('?', StringComparison.Ordinal)
? url[..url.IndexOf('?', StringComparison.Ordinal)]
: url));
if (!string.IsNullOrWhiteSpace(fromUrl) && fromUrl.Length <= 8)
{
return fromUrl.ToLowerInvariant();
}
if (!string.IsNullOrWhiteSpace(contentType))
{
if (contentType.Contains("pdf", StringComparison.OrdinalIgnoreCase))
{
return ".pdf";
}
if (contentType.Contains("png", StringComparison.OrdinalIgnoreCase))
{
return ".png";
}
if (contentType.Contains("jpeg", StringComparison.OrdinalIgnoreCase)
|| contentType.Contains("jpg", StringComparison.OrdinalIgnoreCase))
{
return ".jpg";
}
}
return ".pdf";
}
public async Task<List<OrderFileItemOutput>> UploadAsync( public async Task<List<OrderFileItemOutput>> UploadAsync(
int orderId, int orderId,
string fileType, string fileType,

View File

@ -1,5 +1,7 @@
using System.Data;
using Fake.Application; using Fake.Application;
using Fake.Security.Claims; using Fake.Security.Claims;
using Fake.UnitOfWork;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
@ -21,6 +23,7 @@ public partial class OrderService
/// 创建订单批量异步任务并入队 RabbitMQ。 /// 创建订单批量异步任务并入队 RabbitMQ。
/// </summary> /// </summary>
[HttpPost("/order/batch/jobs")] [HttpPost("/order/batch/jobs")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> CreateBatchJobAsync([FromBody] OrderBatchJobCreateInput input) public Task<OrderBatchJobOutput> CreateBatchJobAsync([FromBody] OrderBatchJobCreateInput input)
{ {
return BatchJobCoordinator.CreateAndEnqueueAsync(input, CurrentUser.GetId<int>()); return BatchJobCoordinator.CreateAndEnqueueAsync(input, CurrentUser.GetId<int>());
@ -73,6 +76,7 @@ public partial class OrderService
/// 异步批量创建轨迹订单(替代前端逐条调用)。 /// 异步批量创建轨迹订单(替代前端逐条调用)。
/// </summary> /// </summary>
[HttpPost("/order/batch/track-upsert")] [HttpPost("/order/batch/track-upsert")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchTrackUpsertAsync([FromBody] OrderBatchJobCreateInput input) public Task<OrderBatchJobOutput> BatchTrackUpsertAsync([FromBody] OrderBatchJobCreateInput input)
{ {
input.Action = OrderBatchActionCodes.TrackUpsertSmallBag; input.Action = OrderBatchActionCodes.TrackUpsertSmallBag;
@ -94,6 +98,7 @@ public partial class OrderService
/// 异步批量预报下游订单。 /// 异步批量预报下游订单。
/// </summary> /// </summary>
[HttpPost("/order/batch/downstream-forward")] [HttpPost("/order/batch/downstream-forward")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchDownstreamForwardAsync([FromBody] OrderBatchJobCreateInput input) public Task<OrderBatchJobOutput> BatchDownstreamForwardAsync([FromBody] OrderBatchJobCreateInput input)
{ {
input.Action = OrderBatchActionCodes.DownstreamForwardCreateOne; input.Action = OrderBatchActionCodes.DownstreamForwardCreateOne;
@ -111,6 +116,7 @@ public partial class OrderService
string.IsNullOrWhiteSpace(authorization) ? null : authorization); string.IsNullOrWhiteSpace(authorization) ? null : authorization);
} }
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
private Task<OrderBatchJobOutput> EnqueueOrderBatchAsync(string action, OrderBatchJobCreateInput input) private Task<OrderBatchJobOutput> EnqueueOrderBatchAsync(string action, OrderBatchJobCreateInput input)
{ {
input.Action = action; input.Action = action;
@ -121,6 +127,7 @@ public partial class OrderService
/// 异步批量支付订单。 /// 异步批量支付订单。
/// </summary> /// </summary>
[HttpPost("/order/batch/pay")] [HttpPost("/order/batch/pay")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchPayAsync([FromBody] OrderBatchJobCreateInput input) => public Task<OrderBatchJobOutput> BatchPayAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.Pay, input); EnqueueOrderBatchAsync(OrderBatchActionCodes.Pay, input);
@ -128,6 +135,7 @@ public partial class OrderService
/// 异步批量预报订单。 /// 异步批量预报订单。
/// </summary> /// </summary>
[HttpPost("/order/batch/pre-declare")] [HttpPost("/order/batch/pre-declare")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchPreDeclareAsync([FromBody] OrderBatchJobCreateInput input) => public Task<OrderBatchJobOutput> BatchPreDeclareAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.PreDeclare, input); EnqueueOrderBatchAsync(OrderBatchActionCodes.PreDeclare, input);
@ -135,6 +143,7 @@ public partial class OrderService
/// 异步批量拦截订单。 /// 异步批量拦截订单。
/// </summary> /// </summary>
[HttpPost("/order/batch/intercept")] [HttpPost("/order/batch/intercept")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchInterceptAsync([FromBody] OrderBatchJobCreateInput input) => public Task<OrderBatchJobOutput> BatchInterceptAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.Intercept, input); EnqueueOrderBatchAsync(OrderBatchActionCodes.Intercept, input);
@ -142,6 +151,7 @@ public partial class OrderService
/// 异步批量取消拦截。 /// 异步批量取消拦截。
/// </summary> /// </summary>
[HttpPost("/order/batch/cancel-intercept")] [HttpPost("/order/batch/cancel-intercept")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchCancelInterceptAsync([FromBody] OrderBatchJobCreateInput input) => public Task<OrderBatchJobOutput> BatchCancelInterceptAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.CancelIntercept, input); EnqueueOrderBatchAsync(OrderBatchActionCodes.CancelIntercept, input);
@ -149,6 +159,7 @@ public partial class OrderService
/// 异步批量删除订单。 /// 异步批量删除订单。
/// </summary> /// </summary>
[HttpPost("/order/batch/delete")] [HttpPost("/order/batch/delete")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchDeleteAsync([FromBody] OrderBatchJobCreateInput input) => public Task<OrderBatchJobOutput> BatchDeleteAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.Delete, input); EnqueueOrderBatchAsync(OrderBatchActionCodes.Delete, input);
@ -156,6 +167,7 @@ public partial class OrderService
/// 异步批量打印面单。 /// 异步批量打印面单。
/// </summary> /// </summary>
[HttpPost("/order/batch/print")] [HttpPost("/order/batch/print")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchPrintAsync([FromBody] OrderBatchJobCreateInput input) => public Task<OrderBatchJobOutput> BatchPrintAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.Print, input); EnqueueOrderBatchAsync(OrderBatchActionCodes.Print, input);
@ -163,6 +175,7 @@ public partial class OrderService
/// 异步批量标记问题件。 /// 异步批量标记问题件。
/// </summary> /// </summary>
[HttpPost("/order/batch/mark-problematic")] [HttpPost("/order/batch/mark-problematic")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchMarkProblematicAsync([FromBody] OrderBatchJobCreateInput input) => public Task<OrderBatchJobOutput> BatchMarkProblematicAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkProblematic, input); EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkProblematic, input);
@ -170,6 +183,7 @@ public partial class OrderService
/// 异步批量重新排货。 /// 异步批量重新排货。
/// </summary> /// </summary>
[HttpPost("/order/batch/reallocate")] [HttpPost("/order/batch/reallocate")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchReallocateAsync([FromBody] OrderBatchJobCreateInput input) => public Task<OrderBatchJobOutput> BatchReallocateAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.Reallocate, input); EnqueueOrderBatchAsync(OrderBatchActionCodes.Reallocate, input);
@ -177,6 +191,7 @@ public partial class OrderService
/// 异步批量标记已认领。 /// 异步批量标记已认领。
/// </summary> /// </summary>
[HttpPost("/order/batch/mark-claimed")] [HttpPost("/order/batch/mark-claimed")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchMarkClaimedAsync([FromBody] OrderBatchJobCreateInput input) => public Task<OrderBatchJobOutput> BatchMarkClaimedAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkClaimed, input); EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkClaimed, input);
@ -184,6 +199,7 @@ public partial class OrderService
/// 异步批量更新处理状态。 /// 异步批量更新处理状态。
/// </summary> /// </summary>
[HttpPost("/order/batch/handle-status-update")] [HttpPost("/order/batch/handle-status-update")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public Task<OrderBatchJobOutput> BatchHandleStatusUpdateAsync([FromBody] OrderBatchJobCreateInput input) => public Task<OrderBatchJobOutput> BatchHandleStatusUpdateAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.HandleStatusUpdate, input); EnqueueOrderBatchAsync(OrderBatchActionCodes.HandleStatusUpdate, input);

View File

@ -1,5 +1,6 @@
using Fake.Application; using Fake.Application;
using Fake.Domain.Exceptions; using Fake.Domain.Exceptions;
using Fake.UnitOfWork;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.Itfx; using Oms.Application.Integrations.Itfx;
@ -36,6 +37,7 @@ public partial class OrderService
/// 按订单 Id 组装 createOrderOne 并转发至 itfx 下游。 /// 按订单 Id 组装 createOrderOne 并转发至 itfx 下游。
/// </summary> /// </summary>
[HttpPost("/order/downstream-forward-create-one")] [HttpPost("/order/downstream-forward-create-one")]
[UnitOfWork(IsTransactional = true)]
public async Task<OrderDownstreamForwardResult> DownstreamForwardCreateOneAsync([FromBody] OrderActionInput input) public async Task<OrderDownstreamForwardResult> DownstreamForwardCreateOneAsync([FromBody] OrderActionInput input)
{ {
var (order, body) = await LoadDownstreamCreateOneContextAsync(input.Id); var (order, body) = await LoadDownstreamCreateOneContextAsync(input.Id);
@ -55,9 +57,11 @@ public partial class OrderService
&& ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data) && ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null) && data != null)
{ {
var previousTrackNo = order.TrackNo;
if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data)) if (ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
{ {
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo);
} }
if (!string.IsNullOrWhiteSpace(data.LabelUrl)) if (!string.IsNullOrWhiteSpace(data.LabelUrl))
@ -82,6 +86,74 @@ public partial class OrderService
}; };
} }
/// <summary>
/// 按订单 Id 组装 getFile 并转发至 itfx 下游;成功时转存文件并回写单号。
/// </summary>
[HttpPost("/order/downstream-forward-get-file")]
public async Task<OrderDownstreamGetFileResult> DownstreamForwardGetFileAsync(
[FromBody] OrderDownstreamGetFileInput input)
{
if (!ItfxGetFileDataParser.TryResolveFileType(input.Type, out var fileType))
{
throw new BusinessException("不支持的文件类型,请选择下游面单/末端面单/POD");
}
var order = await GetOrderOrThrowAsync(input.Id);
var creds = await OrderDownstreamCredentialResolver.ResolveAsync(order);
var body = new ItfxGetFileBody
{
ClientNo = order.ClientNo?.Trim() ?? string.Empty,
TrackNo = order.TrackNo?.Trim() ?? string.Empty,
ServiceNo = order.DownstreamServiceNo?.Trim() ?? string.Empty,
Type = input.Type
};
var forward = await ItfxForwardClient.ForwardWithSubscribeAsync(
ItfxGetFileDataParser.StandardCode,
creds.SupplierCode,
creds.AppKey,
creds.AppSecret,
body);
string? storedFileUrl = null;
if (forward.Success
&& ItfxGetFileDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null)
{
var previousTrackNo = order.TrackNo;
if (ItfxGetFileDataParser.ApplyToOrder(order, data))
{
await OrderRepository.UpdateAsync(order);
await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo);
}
if (!string.IsNullOrWhiteSpace(data.Url))
{
var saved = await OrderFileManager.AddOrReplaceFromRemoteUrlAsync(
order.Id,
fileType,
data.Url!,
CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
storedFileUrl = saved.FileUrl;
}
}
return new OrderDownstreamGetFileResult
{
Success = forward.Success,
SupplierCode = creds.SupplierCode,
Type = input.Type,
FileType = fileType,
Url = forward.Url,
RequestJson = forward.RequestJson,
StatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
StoredFileUrl = storedFileUrl,
DownstreamServiceNo = order.DownstreamServiceNo,
TrackNo = order.TrackNo,
};
}
private async Task<(Domain.OrdersAggregate.Entities.Order order, ItfxCreateOrderOneBody body)> LoadDownstreamCreateOneContextAsync( private async Task<(Domain.OrdersAggregate.Entities.Order order, ItfxCreateOrderOneBody body)> LoadDownstreamCreateOneContextAsync(
int orderId) int orderId)
{ {

View File

@ -48,6 +48,7 @@ public partial class OrderService
order.ClientNo = newClientNo; order.ClientNo = newClientNo;
} }
var previousTrackNo = order.TrackNo;
if (input.TrackNo != null) if (input.TrackNo != null)
{ {
order.TrackNo = string.IsNullOrWhiteSpace(input.TrackNo) ? null : input.TrackNo.Trim(); order.TrackNo = string.IsNullOrWhiteSpace(input.TrackNo) ? null : input.TrackNo.Trim();
@ -155,6 +156,11 @@ public partial class OrderService
afterSnap, afterSnap,
remark); remark);
if (input.TrackNo != null)
{
await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo);
}
return (await MapOrderOutputsAsync([order])).First(); return (await MapOrderOutputsAsync([order])).First();
} }

View File

@ -3201,6 +3201,25 @@ public partial class OrderService
await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
} }
/// <summary>
/// 跟踪号相对旧值有变更且新值非空时,再创建/更新一次轨迹订单。
/// </summary>
private async Task EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(
Order order,
string? previousTrackNo,
bool appendInitialTrack = true)
{
var next = order.TrackNo?.Trim() ?? string.Empty;
var prev = previousTrackNo?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(next)
|| string.Equals(next, prev, StringComparison.Ordinal))
{
return;
}
await EnqueueSmallBagTrackUpsertAsync(order, customer: null, receiver: null, appendInitialTrack);
}
private async Task EnqueueLifecycleTrackAppendAsync(Order order, string trackCode) private async Task EnqueueLifecycleTrackAppendAsync(Order order, string trackCode)
{ {
if (string.IsNullOrWhiteSpace(order.ServiceNo)) if (string.IsNullOrWhiteSpace(order.ServiceNo))

View File

@ -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;
/// <summary>
/// 自动触发规则 Excel 模板与导入解析。
/// </summary>
public static class AutoTriggerImportHelper
{
public const string TemplateSheetName = "导入模板";
public const string EnumSheetName = "枚举值";
private static readonly Dictionary<string, string> 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<byte[]> BuildTemplateBytesAsync()
{
var templateRows = new List<Dictionary<string, object?>>
{
new()
{
["规则名称"] = "英国超尺寸拦截示例",
["优先级"] = 100,
["状态"] = "启用",
["操作"] = "拦截",
["触发时机"] = "下单",
["触发条件"] = "{\n \"expression\": \"收件国家 == \\\"GB\\\"\"\n}",
["提示语"] = "超尺寸,禁止下单",
["通知人"] = ""
}
};
var enumRows = new List<Dictionary<string, object?>>
{
new() { ["枚举类型"] = "状态", ["编码"] = "2", ["名称"] = "启用" },
new() { ["枚举类型"] = "状态", ["编码"] = "1", ["名称"] = "禁用" },
new() { ["枚举类型"] = "操作", ["编码"] = "intercept", ["名称"] = "拦截" },
new() { ["枚举类型"] = "操作", ["编码"] = "warn", ["名称"] = "警告" },
new() { ["枚举类型"] = "操作", ["编码"] = "problem", ["名称"] = "问题件" },
new() { ["枚举类型"] = "触发时机", ["编码"] = "order_create", ["名称"] = "下单" },
new() { ["枚举类型"] = "触发时机", ["编码"] = "inbound", ["名称"] = "入库" },
new() { ["枚举类型"] = "触发时机", ["编码"] = "outbound", ["名称"] = "出库" },
};
var sheets = new Dictionary<string, object>
{
[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<string> 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<List<(int RowNumber, AutoTrigger Entity)>> 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<string, object> 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<string, object> rowDict)
{
var mapped = new Dictionary<string, string?>(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<string, object> rowDict)
{
foreach (var value in rowDict.Values)
{
if (!string.IsNullOrWhiteSpace(NormalizeCell(value)))
{
return false;
}
}
return true;
}
}

View File

@ -1,6 +1,7 @@
using FluentAssertions; using FluentAssertions;
using Fake.Application; using Fake.Application;
using Fake.ObjectStorage; using Fake.ObjectStorage;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moq; using Moq;
@ -36,6 +37,8 @@ public class OrderBatchJobCoordinatorGovernanceTests
actionExecutor.Object, actionExecutor.Object,
batchOptions, batchOptions,
NullLogger<OrderBatchJobProcessor>.Instance); NullLogger<OrderBatchJobProcessor>.Instance);
var unitOfWorkManager = new Mock<IUnitOfWorkManager>(MockBehavior.Loose);
unitOfWorkManager.SetupGet(x => x.Current).Returns((IUnitOfWork?)null);
_coordinator = new OrderBatchJobCoordinator( _coordinator = new OrderBatchJobCoordinator(
_batchJobRepository.Object, _batchJobRepository.Object,
_orderRepository.Object, _orderRepository.Object,
@ -44,6 +47,7 @@ public class OrderBatchJobCoordinatorGovernanceTests
_fileStorage.Object, _fileStorage.Object,
_publisher.Object, _publisher.Object,
processor, processor,
unitOfWorkManager.Object,
batchOptions, batchOptions,
NullLogger<OrderBatchJobCoordinator>.Instance); NullLogger<OrderBatchJobCoordinator>.Instance);
} }

View File

@ -20,7 +20,7 @@ public class SmallBagTrackUpsertRequestBuilderTests
} }
[Fact] [Fact]
public void ResolveTrackNo_FallsBackToServiceNoWhenTrackNoEmpty() public void ResolveTrackNo_EmptyWhenTrackNoMissing()
{ {
var order = new Order var order = new Order
{ {
@ -28,7 +28,7 @@ public class SmallBagTrackUpsertRequestBuilderTests
TrackNo = null TrackNo = null
}; };
Assert.Equal("IDE2607221859123456", SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order)); Assert.Equal(string.Empty, SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order));
} }
[Fact] [Fact]