diff --git a/db.sql b/db.sql
index 098d82b..b1963c6 100644
--- a/db.sql
+++ b/db.sql
@@ -91,8 +91,6 @@ DROP TABLE IF EXISTS `t_order_weight`;
DROP TABLE IF EXISTS `t_order_declaration`;
DROP TABLE IF EXISTS `t_order_recipient`;
DROP TABLE IF EXISTS `t_order_sender`;
-DROP TABLE IF EXISTS `t_order_task_fail`;
-DROP TABLE IF EXISTS `t_order_task`;
DROP TABLE IF EXISTS `t_order_batch_job_item`;
DROP TABLE IF EXISTS `t_order_batch_job`;
DROP TABLE IF EXISTS `t_order_template`;
@@ -224,42 +222,6 @@ CREATE TABLE `t_order_template` (
KEY `idx_ord_tpl_customer_type` (`CustomerId`, `Type`)
) COMMENT='下单模板表';
-CREATE TABLE `t_order_task` (
- `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
- `TaskName` VARCHAR(128) NOT NULL COMMENT '任务名称',
- `Quantity` INT NULL COMMENT '数量',
- `SuccessCount` INT NULL COMMENT '成功数',
- `FailCount` INT NULL COMMENT '失败数',
- `Status` VARCHAR(16) NOT NULL COMMENT '任务状态:processing/completed/pending',
- `TemplateId` INT NULL COMMENT '模板ID',
- `CustomerId` INT NULL COMMENT '客户ID',
- `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
- `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
- `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
- `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `UpdateUserId` INT NULL COMMENT '更新人',
- `UpdateTime` DATETIME NULL COMMENT '更新时间',
- PRIMARY KEY (`Id`),
- KEY `idx_ord_task_customer_status_ctime` (`CustomerId`, `Status`, `CreateTime`),
- KEY `idx_ord_task_template` (`TemplateId`)
-) COMMENT='下单任务表';
-
-CREATE TABLE `t_order_task_fail` (
- `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
- `TaskId` INT NOT NULL COMMENT '任务ID',
- `RowNumber` INT NOT NULL COMMENT 'Excel行号',
- `Reason` VARCHAR(512) NOT NULL COMMENT '失败原因',
- `RawDataJson` JSON NULL COMMENT '原始行数据',
- `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
- `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
- `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `UpdateUserId` INT NULL COMMENT '更新人',
- `UpdateTime` DATETIME NULL COMMENT '更新时间',
- PRIMARY KEY (`Id`),
- KEY `idx_ord_task_fail_task` (`TaskId`),
- KEY `idx_ord_task_fail_row` (`TaskId`, `RowNumber`)
-) COMMENT='下单任务失败明细表';
-
CREATE TABLE `t_order_batch_job` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`Action` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '批量操作类型:pay/pre-declare/intercept/parcel-create 等',
diff --git a/design.md b/design.md
index add954e..736846a 100644
--- a/design.md
+++ b/design.md
@@ -65,13 +65,13 @@
- 我的订单:列表、详情、支付、预报、拦截、取消拦截、删除、打印、导出
- 我要查价:按目的国、运输方式、重量、货物类型返回价格方案
-- 我要下单:模板下载、Excel 导入、任务跟踪
+- 我要下单:模板下载、Excel 导入、批量任务跟踪
核心表:
- `t_order`
- `t_order_template`
-- `t_order_task`
+- `t_order_batch_job` / `t_order_batch_job_item`
- `t_order_sender`
- `t_order_recipient`
- `t_order_declaration`
@@ -403,10 +403,9 @@ OMS 规格文档展示的是 kebab-case 路径示例,例如:
建议至少拆分为以下应用服务:
-- `OrderService`
+- `OrderService`(含批量任务 `OrderService.BatchJob`)
- `OrderPriceCheckService`
- `OrderTemplateService`
-- `OrderTaskService`
- `SalesProductService`
- `SalesProductPriceService`
- `SalesProductAllocationService`
@@ -432,7 +431,7 @@ OMS 规格文档展示的是 kebab-case 路径示例,例如:
以下场景必须显式事务:
- 创建订单时同时写主表、地址、申报、件重尺、费用
-- 批量导入任务落库与任务结果回写
+- 批量任务落库与子项结果回写(`t_order_batch_job` / `t_order_batch_job_item`)
- 保存价格项时同时覆盖因素与价格明细
- 发布与回滚时版本快照回写
- 充值审核通过并更新余额
diff --git a/sql/migrate_drop_order_task_merge_batch.sql b/sql/migrate_drop_order_task_merge_batch.sql
new file mode 100644
index 0000000..4a8f194
--- /dev/null
+++ b/sql/migrate_drop_order_task_merge_batch.sql
@@ -0,0 +1,5 @@
+-- 合并下单任务表到批量任务:删除 t_order_task / t_order_task_fail
+-- 历史导入任务数据不再保留;Excel 导入统一走 t_order_batch_job(action=import-row)
+
+DROP TABLE IF EXISTS `t_order_task_fail`;
+DROP TABLE IF EXISTS `t_order_task`;
diff --git a/src/Oms.Application/Dtos/Orders/OrderBatchJobDtos.cs b/src/Oms.Application/Dtos/Orders/OrderBatchJobDtos.cs
index 1e275d2..4c10a61 100644
--- a/src/Oms.Application/Dtos/Orders/OrderBatchJobDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderBatchJobDtos.cs
@@ -35,6 +35,10 @@ public class OrderBatchJobOutput
public int CancelledCount { get; set; }
+ public int? TemplateId { get; set; }
+
+ public string? TaskName { get; set; }
+
public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
diff --git a/src/Oms.Application/Dtos/Orders/OrderBatchPayloadDtos.cs b/src/Oms.Application/Dtos/Orders/OrderBatchPayloadDtos.cs
index b1f6ed5..8d3c032 100644
--- a/src/Oms.Application/Dtos/Orders/OrderBatchPayloadDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderBatchPayloadDtos.cs
@@ -34,8 +34,6 @@ public class OrderBatchPayloadJobCreateInput
///
public class OrderBatchImportRowPayload
{
- public int TaskId { get; set; }
-
public int RowNumber { get; set; }
public int CustomerId { get; set; }
@@ -50,7 +48,7 @@ public class OrderBatchImportRowPayload
///
public class OrderBatchImportJobPayload
{
- public int TaskId { get; set; }
+ public string TaskName { get; set; } = string.Empty;
public int TemplateId { get; set; }
diff --git a/src/Oms.Application/Dtos/Orders/OrderTaskDetailOutput.cs b/src/Oms.Application/Dtos/Orders/OrderTaskDetailOutput.cs
deleted file mode 100644
index 49d3b70..0000000
--- a/src/Oms.Application/Dtos/Orders/OrderTaskDetailOutput.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-namespace Oms.Application.Dtos.Orders;
-
-///
-/// 订单任务详情出参。
-///
-public class OrderTaskDetailOutput
-{
- ///
- /// 主键。
- ///
- public int Id { get; set; }
-
- ///
- /// 任务名称。
- ///
- public string TaskName { get; set; } = string.Empty;
-
- ///
- /// 数量。
- ///
- public int? Quantity { get; set; }
-
- ///
- /// 成功数量。
- ///
- public int? SuccessCount { get; set; }
-
- ///
- /// 失败数量。
- ///
- public int? FailCount { get; set; }
-
- ///
- /// 状态。
- ///
- public string Status { get; set; } = string.Empty;
-
- ///
- /// 模板主键。
- ///
- public int? TemplateId { get; set; }
-
- ///
- /// 创建时间。
- ///
- public DateTime CreateTime { get; set; }
-
- ///
- /// 更新时间。
- ///
- public DateTime? UpdateTime { get; set; }
-
- ///
- /// 失败明细集合。
- ///
- public List FailList { get; set; } = [];
-}
diff --git a/src/Oms.Application/Dtos/Orders/OrderTaskFailOutput.cs b/src/Oms.Application/Dtos/Orders/OrderTaskFailOutput.cs
deleted file mode 100644
index 4b0b7b8..0000000
--- a/src/Oms.Application/Dtos/Orders/OrderTaskFailOutput.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-namespace Oms.Application.Dtos.Orders;
-
-///
-/// 订单任务失败项出参。
-///
-public class OrderTaskFailOutput
-{
- ///
- /// 主键。
- ///
- public int Id { get; set; }
-
- ///
- /// 任务主键。
- ///
- public int TaskId { get; set; }
-
- ///
- /// 行号。
- ///
- public int RowNumber { get; set; }
-
- ///
- /// 失败原因。
- ///
- public string Reason { get; set; } = string.Empty;
-
- ///
- /// 原始数据 JSON。
- ///
- public object? RawDataJson { get; set; }
-}
diff --git a/src/Oms.Application/Dtos/Orders/OrderTaskListInput.cs b/src/Oms.Application/Dtos/Orders/OrderTaskListInput.cs
deleted file mode 100644
index 4024b9d..0000000
--- a/src/Oms.Application/Dtos/Orders/OrderTaskListInput.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace Oms.Application.Dtos.Orders;
-
-///
-/// 订单任务列表查询入参。
-///
-public class OrderTaskListInput
-{
- ///
- /// 页码,从 1 开始。
- ///
- public int Page { get; set; } = 1;
-
- ///
- /// 每页条数。
- ///
- public int PageSize { get; set; } = 20;
-
- ///
- /// 状态。
- ///
- public string? Status { get; set; }
-
- ///
- /// 关键词。
- ///
- public string? Keyword { get; set; }
-}
diff --git a/src/Oms.Application/Dtos/Orders/OrderTaskOutput.cs b/src/Oms.Application/Dtos/Orders/OrderTaskOutput.cs
deleted file mode 100644
index e101001..0000000
--- a/src/Oms.Application/Dtos/Orders/OrderTaskOutput.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-namespace Oms.Application.Dtos.Orders;
-
-///
-/// 订单任务出参。
-///
-public class OrderTaskOutput
-{
- ///
- /// 主键。
- ///
- public int Id { get; set; }
-
- ///
- /// 任务名称。
- ///
- public string TaskName { get; set; } = string.Empty;
-
- ///
- /// 数量。
- ///
- public int? Quantity { get; set; }
-
- ///
- /// 状态。
- ///
- public string Status { get; set; } = string.Empty;
-
- ///
- /// 模板主键。
- ///
- public int? TemplateId { get; set; }
-
- ///
- /// 创建时间。
- ///
- public DateTime CreateTime { get; set; }
-
- ///
- /// 更新时间。
- ///
- public DateTime? UpdateTime { get; set; }
-}
diff --git a/src/Oms.Application/Dtos/Orders/OrderUploadOutput.cs b/src/Oms.Application/Dtos/Orders/OrderUploadOutput.cs
index b736848..d5abf12 100644
--- a/src/Oms.Application/Dtos/Orders/OrderUploadOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderUploadOutput.cs
@@ -1,22 +1,17 @@
namespace Oms.Application.Dtos.Orders;
///
-/// 订单结果出参。
+/// 订单 Excel 导入出参(统一返回批量任务 ID)。
///
public class OrderUploadOutput
{
///
- /// 任务主键。
+ /// 批量任务主键。
///
- public int TaskId { get; set; }
+ public int JobId { get; set; }
///
- /// 关联批量 Job(异步导入时返回)。
- ///
- public int? JobId { get; set; }
-
- ///
- /// 是否异步处理中。
+ /// 是否异步处理中(RabbitMQ 队列)。
///
public bool Async { get; set; }
}
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
index cee1d9d..d0f0c01 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs
@@ -1,6 +1,7 @@
using Fake.Application;
using Fake.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
@@ -23,6 +24,8 @@ public sealed class OrderBatchJobCoordinator(
IOrderRepository orderRepository,
ICountryRepository countryRepository,
IOrderBatchJobPublisher publisher,
+ OrderBatchJobProcessor batchJobProcessor,
+ IOptions batchOptions,
ILogger logger) : IScopedDependency
{
private const int DefaultMaxBatchSize = 200;
@@ -168,6 +171,83 @@ public sealed class OrderBatchJobCoordinator(
throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置");
}
+ var (job, items, itemsInput, created) = await InsertPayloadJobAsync(input, operatorUserId, cancellationToken);
+ if (!created)
+ {
+ return await MapJobOutputAsync(job, cancellationToken);
+ }
+
+ for (var index = 0; index < items.Count; index++)
+ {
+ await publisher.PublishAsync(
+ BuildPayloadMessage(job, items[index], itemsInput[index], input.CustomerId, operatorUserId),
+ cancellationToken);
+ }
+
+ logger.LogInformation(
+ "Created payload batch job {JobId} action={Action} total={Total}",
+ job.Id,
+ job.Action,
+ job.Total);
+
+ return await MapJobOutputAsync(job, cancellationToken);
+ }
+
+ ///
+ /// 同步创建并逐条处理 Payload 批量任务(MQ 未启用时的 Excel 导入等)。
+ ///
+ public async Task CreatePayloadJobAndProcessSyncAsync(
+ OrderBatchPayloadJobCreateInput input,
+ int operatorUserId,
+ CancellationToken cancellationToken = default)
+ {
+ var (job, items, itemsInput, created) = await InsertPayloadJobAsync(input, operatorUserId, cancellationToken);
+ if (!created)
+ {
+ return await MapJobOutputAsync(job, cancellationToken);
+ }
+
+ var maxAttempts = Math.Max(1, batchOptions.Value.MaxSystemRetries);
+
+ for (var index = 0; index < items.Count; index++)
+ {
+ var item = items[index];
+ var message = BuildPayloadMessage(job, item, itemsInput[index], input.CustomerId, operatorUserId);
+
+ for (var attempt = 0; attempt < maxAttempts; attempt++)
+ {
+ try
+ {
+ await batchJobProcessor.ProcessAsync(message, cancellationToken);
+ break;
+ }
+ catch (InvalidOperationException) when (attempt < maxAttempts - 1)
+ {
+ var refreshed = await batchJobRepository.GetItemByIdAsync(item.Id, cancellationToken);
+ if (refreshed?.Status is OrderBatchJobItemStatusCodes.Failed or OrderBatchJobItemStatusCodes.Success)
+ {
+ break;
+ }
+ }
+ }
+ }
+
+ logger.LogInformation(
+ "Processed payload batch job synchronously {JobId} action={Action} total={Total}",
+ job.Id,
+ job.Action,
+ job.Total);
+
+ var refreshedJob = await batchJobRepository.GetByIdAsync(job.Id, cancellationToken)
+ ?? throw new BusinessException("批量任务不存在");
+ return await MapJobOutputAsync(refreshedJob, cancellationToken);
+ }
+
+ private async Task<(OrderBatchJob Job, List Items, List ItemsInput, bool Created)> InsertPayloadJobAsync(
+ OrderBatchPayloadJobCreateInput input,
+ int operatorUserId,
+ CancellationToken cancellationToken)
+ {
var action = input.Action?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(action))
{
@@ -192,7 +272,8 @@ public sealed class OrderBatchJobCoordinator(
var existing = await batchJobRepository.GetByRequestIdAsync(input.RequestId.Trim(), cancellationToken);
if (existing != null)
{
- return await MapJobOutputAsync(existing, cancellationToken);
+ var existingItems = await batchJobRepository.GetItemsByJobIdAsync(existing.Id, cancellationToken);
+ return (existing, existingItems, itemsInput, false);
}
}
@@ -246,33 +327,43 @@ public sealed class OrderBatchJobCoordinator(
}
await batchJobRepository.InsertItemsAsync(items, cancellationToken);
+ return (job, items, itemsInput, true);
+ }
- for (var index = 0; index < items.Count; index++)
+ private static OrderBatchJobMessage BuildPayloadMessage(
+ OrderBatchJob job,
+ OrderBatchJobItem item,
+ OrderBatchPayloadItemInput source,
+ int? customerId,
+ int operatorUserId) =>
+ new()
{
- var item = items[index];
- var source = itemsInput[index];
- var message = new OrderBatchJobMessage
- {
- JobId = job.Id,
- JobItemId = item.Id,
- Action = action,
- OrderId = item.OrderId,
- CustomerId = input.CustomerId,
- PayloadJson = source.PayloadJson,
- OperatorUserId = operatorUserId,
- MessageId = item.MessageId ?? $"{job.Id}:{index + 1}:{action}",
- };
+ JobId = job.Id,
+ JobItemId = item.Id,
+ Action = job.Action,
+ OrderId = item.OrderId,
+ CustomerId = customerId,
+ PayloadJson = source.PayloadJson,
+ OperatorUserId = operatorUserId,
+ MessageId = item.MessageId ?? $"{job.Id}:{item.Id}:{job.Action}",
+ };
- await publisher.PublishAsync(message, cancellationToken);
+ private static void ApplyImportMetadata(OrderBatchJobOutput output, OrderBatchJob job)
+ {
+ if (!string.Equals(job.Action, OrderBatchActionCodes.ImportRow, StringComparison.OrdinalIgnoreCase)
+ || string.IsNullOrWhiteSpace(job.PayloadJson))
+ {
+ return;
}
- logger.LogInformation(
- "Created payload batch job {JobId} action={Action} total={Total}",
- job.Id,
- action,
- job.Total);
+ var meta = JsonConvert.DeserializeObject(job.PayloadJson);
+ if (meta == null)
+ {
+ return;
+ }
- return await MapJobOutputAsync(job, cancellationToken);
+ output.TemplateId = meta.TemplateId > 0 ? meta.TemplateId : null;
+ output.TaskName = string.IsNullOrWhiteSpace(meta.TaskName) ? null : meta.TaskName.Trim();
}
public async Task PublishTrackUpsertAsync(SmallBagTrackUpsertRequest request, int orderId, int? customerId)
@@ -470,7 +561,7 @@ public sealed class OrderBatchJobCoordinator(
await batchJobRepository.UpdateJobAsync(job, cancellationToken);
}
- return new OrderBatchJobOutput
+ var output = new OrderBatchJobOutput
{
JobId = job.Id,
Action = job.Action,
@@ -485,6 +576,8 @@ public sealed class OrderBatchJobCoordinator(
FinishedAt = job.FinishedAt,
CreateTime = job.CreateTime,
};
+ ApplyImportMetadata(output, job);
+ return output;
}
public async Task> GetJobListAsync(
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs
index 691fca8..e3b09df 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs
@@ -15,7 +15,6 @@ namespace Oms.Application.Services.Orders.Batch;
///
public sealed class OrderBatchJobProcessor(
IOrderBatchJobRepository batchJobRepository,
- IOrderTaskRepository orderTaskRepository,
IOrderRepository orderRepository,
IOrderBatchActionExecutor actionExecutor,
IOptions batchOptions,
@@ -99,11 +98,6 @@ public sealed class OrderBatchJobProcessor(
OrderBatchJobItemFeedback.ApplyBusinessFailure(item, result, action);
await batchJobRepository.UpdateItemAsync(item, cancellationToken);
- if (message.Action == OrderBatchActionCodes.ImportRow)
- {
- await RecordImportTaskFailAsync(message, result.FailReason, cancellationToken);
- }
-
await RefreshJobSummaryAsync(item.JobId, cancellationToken);
return;
}
@@ -164,80 +158,5 @@ public sealed class OrderBatchJobProcessor(
}
await batchJobRepository.UpdateJobAsync(job, cancellationToken);
- await SyncOrderTaskFromJobAsync(job, cancellationToken);
- }
-
- private async Task SyncOrderTaskFromJobAsync(OrderBatchJob job, CancellationToken cancellationToken)
- {
- if (job.Action != OrderBatchActionCodes.ImportRow || string.IsNullOrWhiteSpace(job.PayloadJson))
- {
- return;
- }
-
- var meta = Newtonsoft.Json.JsonConvert.DeserializeObject(job.PayloadJson);
- if (meta == null || meta.TaskId <= 0)
- {
- return;
- }
-
- var task = await orderTaskRepository.FirstOrDefaultAsync(x => x.Id == meta.TaskId, cancellationToken);
- if (task == null)
- {
- return;
- }
-
- task.SuccessCount = job.SuccessCount;
- task.FailCount = job.FailCount;
- task.UpdateTime = DateTime.Now;
-
- if (job.Status is OrderBatchJobStatusCodes.Completed or OrderBatchJobStatusCodes.Failed or OrderBatchJobStatusCodes.Cancelled)
- {
- task.Status = OrderTaskStatusCodes.Completed;
- }
- else if (job.Status == OrderBatchJobStatusCodes.Running)
- {
- task.Status = OrderTaskStatusCodes.Processing;
- }
-
- await orderTaskRepository.UpdateAsync(task, cancellationToken: cancellationToken);
- }
-
- private async Task RecordImportTaskFailAsync(
- OrderBatchJobMessage message,
- string? reason,
- CancellationToken cancellationToken)
- {
- if (string.IsNullOrWhiteSpace(message.PayloadJson))
- {
- return;
- }
-
- var payload = Newtonsoft.Json.JsonConvert.DeserializeObject(message.PayloadJson);
- if (payload == null || payload.TaskId <= 0)
- {
- return;
- }
-
- object? rawData;
- try
- {
- rawData = Newtonsoft.Json.JsonConvert.DeserializeObject(payload.RowDataJson) ?? payload.RowDataJson;
- }
- catch
- {
- rawData = payload.RowDataJson;
- }
-
- await orderTaskRepository.InsertFailListAsync([
- new OrderTaskFail
- {
- TaskId = payload.TaskId,
- RowNumber = payload.RowNumber,
- Reason = reason ?? "导入失败",
- RawDataJson = Newtonsoft.Json.JsonConvert.SerializeObject(rawData),
- CreateUserId = message.OperatorUserId > 0 ? message.OperatorUserId : 0,
- CreateTime = DateTime.Now,
- }
- ], cancellationToken);
}
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index 19fc734..ad93154 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -344,21 +344,23 @@ public partial class OrderService
throw new BusinessException("上传文件不能为空");
}
+ var batchInput = await BuildImportBatchJobInputAsync(input);
+ var operatorUserId = CurrentUser.GetId();
+
if (BatchJobPublisher.IsEnabled)
{
- return await ImportOrdersViaQueueAsync(input);
+ var job = await BatchJobCoordinator.CreatePayloadJobAsync(batchInput, operatorUserId);
+ return new OrderUploadOutput { JobId = job.JobId, Async = true };
}
- return await ImportOrdersSyncAsync(input);
+ var syncJob = await BatchJobCoordinator.CreatePayloadJobAndProcessSyncAsync(batchInput, operatorUserId);
+ return new OrderUploadOutput { JobId = syncJob.JobId, Async = false };
}
- private async Task ImportOrdersViaQueueAsync(OrderUploadInput input)
+ private async Task BuildImportBatchJobInputAsync(OrderUploadInput input)
{
- var template = await OrderTemplateRepository.FirstOrDefaultAsync(x => x.Id == input.TemplateId);
- if (template == null)
- {
- throw new BusinessException("模板不存在");
- }
+ var template = await OrderTemplateRepository.FirstOrDefaultAsync(x => x.Id == input.TemplateId)
+ ?? throw new BusinessException("模板不存在");
var effectiveCustomer = await ResolveEffectiveCustomerAsync();
EnsureTemplateAccessible(template, effectiveCustomer.Id);
@@ -378,20 +380,12 @@ public partial class OrderService
throw new BusinessException($"单次最多导入 {ParcelBatchMaxCount} 行");
}
- var task = await OrderTaskRepository.InsertAsync(new OrderTask
- {
- TaskName = $"批量下单_{DateTime.Now:yyyyMMddHHmmss}",
- Quantity = parsedRows.Count,
- SuccessCount = 0,
- FailCount = 0,
- Status = OrderTaskStatusCodes.Processing,
- TemplateId = template.Id,
- CustomerId = effectiveCustomer.Id
- });
+ var taskName = $"批量下单_{DateTime.Now:yyyyMMddHHmmss}";
+ var requestId = $"import-{effectiveCustomer.Id}-{template.Id}-{DateTime.Now:yyyyMMddHHmmssfff}";
var jobPayload = JsonConvert.SerializeObject(new OrderBatchImportJobPayload
{
- TaskId = task.Id,
+ TaskName = taskName,
TemplateId = template.Id,
CustomerId = effectiveCustomer.Id,
});
@@ -404,10 +398,9 @@ public partial class OrderService
OrderId = 0,
ServiceNo = parcelInput.ServiceNo,
ClientNo = parcelInput.ClientNo,
- ItemKey = $"import:{task.Id}:{row.RowNumber}",
+ ItemKey = $"import:{requestId}:{row.RowNumber}",
PayloadJson = JsonConvert.SerializeObject(new OrderBatchImportRowPayload
{
- TaskId = task.Id,
RowNumber = row.RowNumber,
CustomerId = effectiveCustomer.Id,
TemplateId = template.Id,
@@ -416,171 +409,13 @@ public partial class OrderService
};
}).ToList();
- var job = await BatchJobCoordinator.CreatePayloadJobAsync(
- new OrderBatchPayloadJobCreateInput
- {
- Action = OrderBatchActionCodes.ImportRow,
- CustomerId = effectiveCustomer.Id,
- JobPayloadJson = jobPayload,
- RequestId = $"import-task-{task.Id}",
- Items = payloadItems,
- },
- CurrentUser.GetId());
-
- return new OrderUploadOutput
+ return new OrderBatchPayloadJobCreateInput
{
- TaskId = task.Id,
- JobId = job.JobId,
- Async = true,
- };
- }
-
- private async Task ImportOrdersSyncAsync(OrderUploadInput input)
- {
- var template = await OrderTemplateRepository.FirstOrDefaultAsync(x => x.Id == input.TemplateId);
- if (template == null)
- {
- throw new BusinessException("模板不存在");
- }
-
- var effectiveCustomer = await ResolveEffectiveCustomerAsync();
- EnsureTemplateAccessible(template, effectiveCustomer.Id);
- OrderTemplateImportMapping.EnsureTemplateReadyForImport(template);
-
- var mapping = OrderTemplateImportMapping.Resolve(template);
- await using var stream = input.File!.OpenReadStream();
- var parsedRows = await OrderTemplateExcelReader.ReadDataRowsAsync(stream, mapping);
-
- var task = await OrderTaskRepository.InsertAsync(new OrderTask
- {
- TaskName = $"批量下单_{DateTime.Now:yyyyMMddHHmmss}",
- Quantity = 0,
- SuccessCount = 0,
- FailCount = 0,
- Status = OrderTaskStatusCodes.Processing,
- TemplateId = template.Id,
- CustomerId = effectiveCustomer.Id
- });
-
- var quantity = 0;
- var successCount = 0;
- var failCount = 0;
- var failList = new List();
-
- foreach (var row in parsedRows)
- {
- quantity++;
- try
- {
- var parcelInput = OrderTemplateRowMapper.MapToParcelCreateInput(
- row.SourceValues,
- mapping,
- effectiveCustomer.Id);
- await CreateParcelCoreAsync(parcelInput);
- successCount++;
- }
- catch (Exception ex)
- {
- failCount++;
- failList.Add(BuildTaskFail(task.Id, row.RowNumber, ex.Message, row.SourceValues));
- }
- }
-
- if (parsedRows.Count == 0)
- {
- quantity = 1;
- failCount = 1;
- failList.Add(BuildTaskFail(task.Id, 1, "Excel 中未读取到可用数据", new Dictionary()));
- }
-
- if (failList.Count > 0)
- {
- await OrderTaskRepository.InsertFailListAsync(failList);
- }
-
- task.Quantity = quantity;
- task.SuccessCount = successCount;
- task.FailCount = failCount;
- task.Status = OrderTaskStatusCodes.Completed;
- await OrderTaskRepository.UpdateAsync(task);
- return new OrderUploadOutput { TaskId = task.Id };
- }
-
- private async Task> GetTaskOutputsAsync(OrderTaskListInput input)
- {
- var page = OmsDocHelper.NormalizePage(input.Page);
- var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
- var keyword = input.Keyword?.Trim();
- var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
- CustomerRepository,
- CurrentUser,
- null);
- var hasCustomerFilter = customerId.HasValue;
- var customerIdValue = customerId.GetValueOrDefault();
- var predicate = (Expression>)(x =>
- (string.IsNullOrWhiteSpace(input.Status) || x.Status == input.Status) &&
- (string.IsNullOrWhiteSpace(keyword) || x.TaskName.Contains(keyword)) &&
- (!hasCustomerFilter || x.CustomerId == customerIdValue));
- var tasks = await OrderTaskRepository.GetPagedListAsync(
- predicate,
- page,
- pageSize,
- new Dictionary { [nameof(OrderTask.UpdateTime)] = false, [nameof(OrderTask.Id)] = false });
- var total = await OrderTaskRepository.CountAsync(predicate);
-
- return new OmsPageResult
- {
- Items = tasks.Select(x => new OrderTaskOutput
- {
- Id = x.Id,
- TaskName = x.TaskName,
- Quantity = x.Quantity,
- Status = x.Status,
- TemplateId = x.TemplateId,
- CreateTime = x.CreateTime,
- UpdateTime = OmsDocHelper.NullIfDefault(x.UpdateTime)
- }).ToList(),
- Total = total
- };
- }
-
- private async Task GetTaskDetailCoreAsync(int taskId)
- {
- var task = await OrderTaskRepository.FirstOrDefaultAsync(x => x.Id == taskId);
- if (task == null)
- {
- throw new BusinessException("任务不存在");
- }
-
- var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
- CustomerRepository,
- CurrentUser,
- null);
- if (customerId.HasValue && task.CustomerId != customerId.Value)
- {
- throw new BusinessException("任务不存在");
- }
-
- var failList = await OrderTaskRepository.GetFailListAsync(task.Id);
- return new OrderTaskDetailOutput
- {
- Id = task.Id,
- TaskName = task.TaskName,
- Quantity = task.Quantity,
- SuccessCount = task.SuccessCount,
- FailCount = task.FailCount,
- Status = task.Status,
- TemplateId = task.TemplateId,
- CreateTime = task.CreateTime,
- UpdateTime = OmsDocHelper.NullIfDefault(task.UpdateTime),
- FailList = failList.Select(x => new OrderTaskFailOutput
- {
- Id = x.Id,
- TaskId = x.TaskId,
- RowNumber = x.RowNumber,
- Reason = x.Reason,
- RawDataJson = OmsDocHelper.DeserializeJsonObject(x.RawDataJson)
- }).ToList()
+ Action = OrderBatchActionCodes.ImportRow,
+ CustomerId = effectiveCustomer.Id,
+ JobPayloadJson = jobPayload,
+ RequestId = requestId,
+ Items = payloadItems,
};
}
@@ -783,19 +618,6 @@ public partial class OrderService
return forecast?.ChargeableWeight;
}
- private static OrderTaskFail BuildTaskFail(int taskId, int rowNumber, string reason, object rawData)
- {
- return new OrderTaskFail
- {
- TaskId = taskId,
- RowNumber = rowNumber,
- Reason = reason,
- RawDataJson = JsonConvert.SerializeObject(rawData),
- CreateUserId = 0,
- CreateTime = DateTime.Now
- };
- }
-
private static string? GetStringCell(IDictionary row, string key) => row.TryGetValue(key, out var value) ? value?.ToString() : null;
private static int? GetintCell(IDictionary row, string key) => int.TryParse(GetStringCell(row, key), out var value) ? value : null;
private static int? GetIntCell(IDictionary row, string key) => int.TryParse(GetStringCell(row, key), out var value) ? value : null;
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index 3b5aefa..f153507 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -29,7 +29,6 @@ public partial class OrderService : ApplicationService
{
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService();
private IOrderTemplateRepository OrderTemplateRepository => LazyServiceProvider.GetRequiredService();
- private IOrderTaskRepository OrderTaskRepository => LazyServiceProvider.GetRequiredService();
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService();
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService();
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService();
@@ -345,28 +344,6 @@ public partial class OrderService : ApplicationService
return await ImportOrdersAsync(input);
}
- ///
- /// 查询订单任务列表。
- ///
- /// 入参:订单任务列表查询条件。
- /// 订单任务分页结果,包含列表数据和总记录数。
- [HttpGet("/order/task/list")]
- public async Task> GetTaskListAsync([FromQuery] OrderTaskListInput input)
- {
- return await GetTaskOutputsAsync(input);
- }
-
- ///
- /// 查询订单任务详情。
- ///
- /// 入参:任务主键。
- /// 订单任务详情。
- [HttpGet("/order/task/detail")]
- public async Task GetTaskDetailAsync([FromQuery] int taskId)
- {
- return await GetTaskDetailCoreAsync(taskId);
- }
-
///
/// 创建订单包裹。
///
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderTaskStatusCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderTaskStatusCodes.cs
deleted file mode 100644
index 54ffb85..0000000
--- a/src/Oms.Domain/OrdersAggregate/Consts/OrderTaskStatusCodes.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-namespace Oms.Domain.OrdersAggregate.Consts;
-
-///
-/// 订单导入/批量任务状态(t_order_task.Status,字符串落库;与字典 order_task_status 对齐)。
-///
-public static class OrderTaskStatusCodes
-{
- public const string Pending = "pending";
- public const string Processing = "processing";
- public const string Completed = "completed";
-
- public static readonly HashSet All = new(StringComparer.OrdinalIgnoreCase)
- {
- Pending,
- Processing,
- Completed
- };
-
- public static string GetDisplayName(string? code) => code?.Trim().ToLowerInvariant() switch
- {
- Pending => "待处理",
- Processing => "处理中",
- Completed => "已完成",
- _ => code ?? string.Empty
- };
-}
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderTask.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderTask.cs
deleted file mode 100644
index 9a255e0..0000000
--- a/src/Oms.Domain/OrdersAggregate/Entities/OrderTask.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Oms.Domain.Shared;
-using SqlSugar;
-
-namespace Oms.Domain.OrdersAggregate.Entities;
-
-[SugarTable("t_order_task")]
-public class OrderTask : OmsAuditedAggregateRoot
-{
- public string TaskName { get; set; } = string.Empty;
-
- public int? Quantity { get; set; }
-
- public int? SuccessCount { get; set; }
-
- public int? FailCount { get; set; }
-
- public string Status { get; set; } = string.Empty;
-
- public int? TemplateId { get; set; }
-
- public int? CustomerId { get; set; }
-}
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderTaskFail.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderTaskFail.cs
deleted file mode 100644
index 362816c..0000000
--- a/src/Oms.Domain/OrdersAggregate/Entities/OrderTaskFail.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using Oms.Domain.Shared;
-using SqlSugar;
-
-namespace Oms.Domain.OrdersAggregate.Entities;
-
-[SugarTable("t_order_task_fail")]
-public class OrderTaskFail : OmsAuditedEntity
-{
- public int TaskId { get; set; }
-
- public int RowNumber { get; set; }
-
- public string Reason { get; set; } = string.Empty;
-
- [SugarColumn(ColumnDataType = "json", IsNullable = true)]
- public string? RawDataJson { get; set; }
-}
diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderTaskRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderTaskRepository.cs
deleted file mode 100644
index f5731ac..0000000
--- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderTaskRepository.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using Fake.Domain.Repositories;
-using Oms.Domain.OrdersAggregate.Entities;
-
-namespace Oms.Domain.OrdersAggregate.Repositories;
-
-public interface IOrderTaskRepository : IRepository
-{
- Task GetByCustomerAndIdAsync(int customerId, int id, CancellationToken cancellationToken = default);
-
- Task ExistsAsync(int id, CancellationToken cancellationToken = default);
-
- Task> GetFailListAsync(int taskId, CancellationToken cancellationToken = default);
-
- Task InsertFailListAsync(IEnumerable failList, CancellationToken cancellationToken = default);
-}
diff --git a/src/Oms.SqlSugarCore/Orders/OrderTaskRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderTaskRepository.cs
deleted file mode 100644
index 784accd..0000000
--- a/src/Oms.SqlSugarCore/Orders/OrderTaskRepository.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using Fake.DependencyInjection;
-using Oms.Domain.OrdersAggregate.Entities;
-using Oms.Domain.OrdersAggregate.Repositories;
-using Oms.SqlSugarCore.Repositories;
-
-namespace Oms.SqlSugarCore.Orders;
-
-public class OrderTaskRepository : OmsSqlSugarRepository, IOrderTaskRepository, IScopedDependency
-{
- public Task GetByCustomerAndIdAsync(int customerId, int id, CancellationToken cancellationToken = default)
- {
- return FirstOrDefaultAsync(x => x.CustomerId == customerId && x.Id == id, cancellationToken);
- }
-
- public Task ExistsAsync(int id, CancellationToken cancellationToken = default)
- {
- return AnyAsync(x => x.Id == id, cancellationToken);
- }
-
- public async Task> GetFailListAsync(int taskId, CancellationToken cancellationToken = default)
- {
- var db = await GetDbContextAsync(cancellationToken);
- return await db.Queryable()
- .Where(x => x.TaskId == taskId)
- .OrderBy(x => x.RowNumber)
- .ToListAsync(cancellationToken);
- }
-
- public async Task InsertFailListAsync(IEnumerable failList, CancellationToken cancellationToken = default)
- {
- var items = failList.ToArray();
- if (items.Length == 0)
- {
- return;
- }
-
- var db = await GetDbContextAsync(cancellationToken);
- await db.Insertable(items).ExecuteCommandAsync(cancellationToken);
- }
-}
diff --git a/src/Oms.Tests/OrderBatchJobProcessorTests.cs b/src/Oms.Tests/OrderBatchJobProcessorTests.cs
index c84b9ab..0c1f51a 100644
--- a/src/Oms.Tests/OrderBatchJobProcessorTests.cs
+++ b/src/Oms.Tests/OrderBatchJobProcessorTests.cs
@@ -1,207 +1,204 @@
-using FluentAssertions;
-using Microsoft.Extensions.Logging.Abstractions;
-using Microsoft.Extensions.Options;
-using Moq;
-using Oms.Application.Messaging;
-using Oms.Application.Services.Orders.Batch;
-using Oms.Domain.OrdersAggregate.Consts;
-using Oms.Domain.OrdersAggregate.Entities;
-using Oms.Domain.OrdersAggregate.Repositories;
-
-public class OrderBatchJobProcessorTests
-{
- private readonly Mock _batchJobRepository = new(MockBehavior.Strict);
- private readonly Mock _orderTaskRepository = new(MockBehavior.Loose);
- private readonly Mock _orderRepository = new(MockBehavior.Loose);
- private readonly Mock _actionExecutor = new(MockBehavior.Strict);
- private readonly OrderBatchJobProcessor _processor;
-
- public OrderBatchJobProcessorTests()
- {
- _processor = new OrderBatchJobProcessor(
- _batchJobRepository.Object,
- _orderTaskRepository.Object,
- _orderRepository.Object,
- _actionExecutor.Object,
- Options.Create(new OrderBatchRabbitMqOptions { MaxSystemRetries = 5 }),
- NullLogger.Instance);
- }
-
- [Fact]
- public async Task ProcessAsync_BusinessFailure_ShouldMarkFailed()
- {
- var item = CreateItem(id: 10, status: OrderBatchJobItemStatusCodes.Pending);
- var message = CreateMessage(item.Id);
- SetupItemLookup(item);
- _actionExecutor
- .Setup(x => x.ExecuteAsync(message, It.IsAny()))
- .ReturnsAsync(OrderBatchActionExecuteResult.BusinessFail("订单不存在"));
- SetupJobRefresh(item.JobId, [item]);
-
- await _processor.ProcessAsync(message);
-
- item.Status.Should().Be(OrderBatchJobItemStatusCodes.Failed);
- item.FailReason.Should().Be("订单不存在");
- item.ResultJson.Should().Contain("订单不存在");
- }
-
- [Fact]
- public async Task ProcessAsync_SystemFailure_ShouldThrowForRetry()
- {
- var item = CreateItem(id: 11, status: OrderBatchJobItemStatusCodes.Pending);
- var message = CreateMessage(item.Id);
- SetupItemLookup(item);
- _actionExecutor
- .Setup(x => x.ExecuteAsync(message, It.IsAny()))
- .ReturnsAsync(OrderBatchActionExecuteResult.SystemFail("下游超时"));
-
- var act = () => _processor.ProcessAsync(message);
-
- await act.Should().ThrowAsync()
- .WithMessage("*下游超时*");
- item.RetryCount.Should().Be(1);
- item.Status.Should().Be(OrderBatchJobItemStatusCodes.Pending);
- }
-
- [Fact]
- public async Task ProcessAsync_SystemFailureExhausted_ShouldMarkFailedWithoutThrow()
- {
- var processor = new OrderBatchJobProcessor(
- _batchJobRepository.Object,
- _orderTaskRepository.Object,
- _orderRepository.Object,
- _actionExecutor.Object,
- Options.Create(new OrderBatchRabbitMqOptions { MaxSystemRetries = 1 }),
- NullLogger.Instance);
-
- var item = CreateItem(id: 14, status: OrderBatchJobItemStatusCodes.Pending);
- var message = CreateMessage(item.Id);
- SetupItemLookup(item);
- _actionExecutor
- .Setup(x => x.ExecuteAsync(message, It.IsAny()))
- .ReturnsAsync(OrderBatchActionExecuteResult.SystemFail("下游超时"));
- SetupJobRefresh(item.JobId, [item]);
-
- await processor.ProcessAsync(message);
-
- item.Status.Should().Be(OrderBatchJobItemStatusCodes.Failed);
- item.RetryCount.Should().Be(1);
- item.FailReason.Should().Contain("系统重试已达上限");
- }
-
- [Fact]
- public async Task ProcessAsync_CancelledItem_ShouldSkipWithoutExecuting()
- {
- var item = CreateItem(id: 13, status: OrderBatchJobItemStatusCodes.Cancelled);
- var message = CreateMessage(item.Id);
- _batchJobRepository
- .Setup(x => x.GetItemByIdAsync(item.Id, It.IsAny()))
- .ReturnsAsync(item);
-
- await _processor.ProcessAsync(message);
-
- _actionExecutor.Verify(
- x => x.ExecuteAsync(It.IsAny(), It.IsAny()),
- Times.Never);
- }
-
- [Fact]
- public async Task ProcessAsync_FireAndForgetFailure_ShouldNotThrow()
- {
- var message = new OrderBatchJobMessage
- {
- JobId = 0,
- JobItemId = 0,
- Action = OrderBatchActionCodes.TrackUpsertSmallBag,
- OrderId = 99,
- MessageId = "track:99:x",
- };
- _actionExecutor
- .Setup(x => x.ExecuteAsync(message, It.IsAny()))
- .ReturnsAsync(OrderBatchActionExecuteResult.SystemFail("下游超时"));
-
- await _processor.ProcessAsync(message);
-
- _batchJobRepository.Verify(
- x => x.GetItemByIdAsync(It.IsAny(), It.IsAny()),
- Times.Never);
- }
-
- [Fact]
- public async Task ProcessAsync_Success_ShouldPersistClientNo()
- {
- var item = CreateItem(id: 12, status: OrderBatchJobItemStatusCodes.Pending);
- item.Action = OrderBatchActionCodes.ImportRow;
- var message = CreateMessage(item.Id);
- message.Action = OrderBatchActionCodes.ImportRow;
- SetupItemLookup(item);
- _actionExecutor
- .Setup(x => x.ExecuteAsync(message, It.IsAny()))
- .ReturnsAsync(OrderBatchActionExecuteResult.Ok(
- createdOrderId: 9001,
- serviceNo: "ID20260722001",
- clientNo: "CUST-001"));
- SetupJobRefresh(item.JobId, [item]);
-
- await _processor.ProcessAsync(message);
-
- item.Status.Should().Be(OrderBatchJobItemStatusCodes.Success);
- item.OrderId.Should().Be(9001);
- item.ClientNo.Should().Be("CUST-001");
- item.ServiceNo.Should().Be("ID20260722001");
- item.ResultJson.Should().Contain("CUST-001");
- }
-
- private void SetupItemLookup(OrderBatchJobItem item)
- {
- _batchJobRepository
- .Setup(x => x.GetItemByIdAsync(item.Id, It.IsAny()))
- .ReturnsAsync(item);
- _batchJobRepository
- .Setup(x => x.UpdateItemAsync(item, It.IsAny()))
- .Returns(Task.CompletedTask);
- }
-
- private void SetupJobRefresh(int jobId, List items)
- {
- var job = new OrderBatchJob
- {
- Id = jobId,
- Action = OrderBatchActionCodes.Pay,
- Status = OrderBatchJobStatusCodes.Running,
- Total = items.Count,
- };
-
- _batchJobRepository
- .Setup(x => x.GetByIdAsync(jobId, It.IsAny()))
- .ReturnsAsync(job);
- _batchJobRepository
- .Setup(x => x.GetItemsByJobIdAsync(jobId, It.IsAny()))
- .ReturnsAsync(items);
- _batchJobRepository
- .Setup(x => x.UpdateJobAsync(job, It.IsAny()))
- .Returns(Task.CompletedTask);
- }
-
- private static OrderBatchJobItem CreateItem(int id, string status, int retryCount = 0) =>
- new()
- {
- Id = id,
- JobId = 100,
- OrderId = 2000 + id,
- Action = OrderBatchActionCodes.Pay,
- Status = status,
- RetryCount = retryCount,
- };
-
- private static OrderBatchJobMessage CreateMessage(int jobItemId) =>
- new()
- {
- JobId = 100,
- JobItemId = jobItemId,
- Action = OrderBatchActionCodes.Pay,
- OrderId = 2000 + jobItemId,
- OperatorUserId = 1,
- MessageId = $"100:{jobItemId}:pay",
- };
-}
+using FluentAssertions;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Moq;
+using Oms.Application.Messaging;
+using Oms.Application.Services.Orders.Batch;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+
+public class OrderBatchJobProcessorTests
+{
+ private readonly Mock _batchJobRepository = new(MockBehavior.Strict);
+ private readonly Mock _orderRepository = new(MockBehavior.Loose);
+ private readonly Mock _actionExecutor = new(MockBehavior.Strict);
+ private readonly OrderBatchJobProcessor _processor;
+
+ public OrderBatchJobProcessorTests()
+ {
+ _processor = new OrderBatchJobProcessor(
+ _batchJobRepository.Object,
+ _orderRepository.Object,
+ _actionExecutor.Object,
+ Options.Create(new OrderBatchRabbitMqOptions { MaxSystemRetries = 5 }),
+ NullLogger.Instance);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_BusinessFailure_ShouldMarkFailed()
+ {
+ var item = CreateItem(id: 10, status: OrderBatchJobItemStatusCodes.Pending);
+ var message = CreateMessage(item.Id);
+ SetupItemLookup(item);
+ _actionExecutor
+ .Setup(x => x.ExecuteAsync(message, It.IsAny()))
+ .ReturnsAsync(OrderBatchActionExecuteResult.BusinessFail("订单不存在"));
+ SetupJobRefresh(item.JobId, [item]);
+
+ await _processor.ProcessAsync(message);
+
+ item.Status.Should().Be(OrderBatchJobItemStatusCodes.Failed);
+ item.FailReason.Should().Be("订单不存在");
+ item.ResultJson.Should().Contain("订单不存在");
+ }
+
+ [Fact]
+ public async Task ProcessAsync_SystemFailure_ShouldThrowForRetry()
+ {
+ var item = CreateItem(id: 11, status: OrderBatchJobItemStatusCodes.Pending);
+ var message = CreateMessage(item.Id);
+ SetupItemLookup(item);
+ _actionExecutor
+ .Setup(x => x.ExecuteAsync(message, It.IsAny()))
+ .ReturnsAsync(OrderBatchActionExecuteResult.SystemFail("下游超时"));
+
+ var act = () => _processor.ProcessAsync(message);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*下游超时*");
+ item.RetryCount.Should().Be(1);
+ item.Status.Should().Be(OrderBatchJobItemStatusCodes.Pending);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_SystemFailureExhausted_ShouldMarkFailedWithoutThrow()
+ {
+ var processor = new OrderBatchJobProcessor(
+ _batchJobRepository.Object,
+ _orderRepository.Object,
+ _actionExecutor.Object,
+ Options.Create(new OrderBatchRabbitMqOptions { MaxSystemRetries = 1 }),
+ NullLogger.Instance);
+
+ var item = CreateItem(id: 14, status: OrderBatchJobItemStatusCodes.Pending);
+ var message = CreateMessage(item.Id);
+ SetupItemLookup(item);
+ _actionExecutor
+ .Setup(x => x.ExecuteAsync(message, It.IsAny()))
+ .ReturnsAsync(OrderBatchActionExecuteResult.SystemFail("下游超时"));
+ SetupJobRefresh(item.JobId, [item]);
+
+ await processor.ProcessAsync(message);
+
+ item.Status.Should().Be(OrderBatchJobItemStatusCodes.Failed);
+ item.RetryCount.Should().Be(1);
+ item.FailReason.Should().Contain("系统重试已达上限");
+ }
+
+ [Fact]
+ public async Task ProcessAsync_CancelledItem_ShouldSkipWithoutExecuting()
+ {
+ var item = CreateItem(id: 13, status: OrderBatchJobItemStatusCodes.Cancelled);
+ var message = CreateMessage(item.Id);
+ _batchJobRepository
+ .Setup(x => x.GetItemByIdAsync(item.Id, It.IsAny()))
+ .ReturnsAsync(item);
+
+ await _processor.ProcessAsync(message);
+
+ _actionExecutor.Verify(
+ x => x.ExecuteAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_FireAndForgetFailure_ShouldNotThrow()
+ {
+ var message = new OrderBatchJobMessage
+ {
+ JobId = 0,
+ JobItemId = 0,
+ Action = OrderBatchActionCodes.TrackUpsertSmallBag,
+ OrderId = 99,
+ MessageId = "track:99:x",
+ };
+ _actionExecutor
+ .Setup(x => x.ExecuteAsync(message, It.IsAny()))
+ .ReturnsAsync(OrderBatchActionExecuteResult.SystemFail("下游超时"));
+
+ await _processor.ProcessAsync(message);
+
+ _batchJobRepository.Verify(
+ x => x.GetItemByIdAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_Success_ShouldPersistClientNo()
+ {
+ var item = CreateItem(id: 12, status: OrderBatchJobItemStatusCodes.Pending);
+ item.Action = OrderBatchActionCodes.ImportRow;
+ var message = CreateMessage(item.Id);
+ message.Action = OrderBatchActionCodes.ImportRow;
+ SetupItemLookup(item);
+ _actionExecutor
+ .Setup(x => x.ExecuteAsync(message, It.IsAny()))
+ .ReturnsAsync(OrderBatchActionExecuteResult.Ok(
+ createdOrderId: 9001,
+ serviceNo: "ID20260722001",
+ clientNo: "CUST-001"));
+ SetupJobRefresh(item.JobId, [item]);
+
+ await _processor.ProcessAsync(message);
+
+ item.Status.Should().Be(OrderBatchJobItemStatusCodes.Success);
+ item.OrderId.Should().Be(9001);
+ item.ClientNo.Should().Be("CUST-001");
+ item.ServiceNo.Should().Be("ID20260722001");
+ item.ResultJson.Should().Contain("CUST-001");
+ }
+
+ private void SetupItemLookup(OrderBatchJobItem item)
+ {
+ _batchJobRepository
+ .Setup(x => x.GetItemByIdAsync(item.Id, It.IsAny()))
+ .ReturnsAsync(item);
+ _batchJobRepository
+ .Setup(x => x.UpdateItemAsync(item, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ }
+
+ private void SetupJobRefresh(int jobId, List items)
+ {
+ var job = new OrderBatchJob
+ {
+ Id = jobId,
+ Action = OrderBatchActionCodes.Pay,
+ Status = OrderBatchJobStatusCodes.Running,
+ Total = items.Count,
+ };
+
+ _batchJobRepository
+ .Setup(x => x.GetByIdAsync(jobId, It.IsAny()))
+ .ReturnsAsync(job);
+ _batchJobRepository
+ .Setup(x => x.GetItemsByJobIdAsync(jobId, It.IsAny()))
+ .ReturnsAsync(items);
+ _batchJobRepository
+ .Setup(x => x.UpdateJobAsync(job, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ }
+
+ private static OrderBatchJobItem CreateItem(int id, string status, int retryCount = 0) =>
+ new()
+ {
+ Id = id,
+ JobId = 100,
+ OrderId = 2000 + id,
+ Action = OrderBatchActionCodes.Pay,
+ Status = status,
+ RetryCount = retryCount,
+ };
+
+ private static OrderBatchJobMessage CreateMessage(int jobItemId) =>
+ new()
+ {
+ JobId = 100,
+ JobItemId = jobItemId,
+ Action = OrderBatchActionCodes.Pay,
+ OrderId = 2000 + jobItemId,
+ OperatorUserId = 1,
+ MessageId = $"100:{jobItemId}:pay",
+ };
+}
diff --git a/系统结构说明.md b/系统结构说明.md
index 0b62646..f3862a0 100644
--- a/系统结构说明.md
+++ b/系统结构说明.md
@@ -179,10 +179,14 @@ JWT 中业务用户整数 ID 的 Claim 名为 **`mapId`**(对应 `user.userid`
| 文件 | 主要 API | 作用 |
|------|----------|------|
-| `OrderService.cs` | `/order/*`(主文件) | 订单列表/详情/支付/申报/拦截/导出/模板/上传/任务查询 |
+| `OrderService.cs` | `/order/*`(主文件) | 订单列表/详情/支付/申报/拦截/导出/模板/上传 |
+| `OrderService.BatchJob.cs` | `/order/batch/*` | 批量任务列表/详情/子项/重试/取消 |
| `OrderService.ParcelBilling.cs` | `/order/parcel/*` | 小包创建、批量创建、计重算价、费用快照写入 |
| `OrderService.ParcelQuote.cs` | `/order/price-check` | 价格试算:匹配产品与分区报价 |
-| `OrderService.Helpers.cs` | — | 订单通用辅助(状态校验、映射等) |
+| `OrderService.Helpers.cs` | — | 订单通用辅助(导入、状态校验、映射等) |
+| `Batch/OrderBatchJobCoordinator.cs` | — | 批量任务创建、幂等、同步/异步入队 |
+| `Batch/OrderBatchJobProcessor.cs` | — | 批量子项消费与结果回写 |
+| `Batch/OrderBatchJobItemFeedback.cs` | — | 子项成功/失败/重试反馈封装 |
| `ParcelBillingCalculator.cs` | — | 静态计重算价引擎:材积重、分区、卖价费用 |
| `ParcelQuoteContext.cs` | — | 报价上下文(国家、邮编、尺寸重量等) |
| `ParcelQuoteResult.cs` | — | 单产品报价结果模型 |
@@ -288,8 +292,8 @@ JWT 中业务用户整数 ID 的 Claim 名为 **`mapId`**(对应 `user.userid`
| `OrderParcelBatchCreateInput.cs` / `OrderParcelBatchCreateOutput.cs` / `OrderParcelBatchCreateItemOutput.cs` | 批量小包创建 |
| `OrderParcelItemInput.cs` / `OrderParcelReceiverInput.cs` / `OrderParcelSenderInput.cs` | 小包货品与地址 |
| `OrderTemplateListInput.cs` / `OrderTemplateOutput.cs` | 导入模板 |
-| `OrderUploadInput.cs` / `OrderUploadOutput.cs` | Excel 批量上传 |
-| `OrderTaskListInput.cs` / `OrderTaskOutput.cs` / `OrderTaskDetailOutput.cs` / `OrderTaskFailOutput.cs` | 异步导入任务 |
+| `OrderUploadInput.cs` / `OrderUploadOutput.cs` | Excel 批量上传(返回 jobId、async) |
+| `OrderBatchJobListInput.cs` / `OrderBatchJobDtos.cs` | 批量任务列表/详情/子项 DTO |
| `ProductBillingSnapshotDtos.cs` | 计费快照(产品、价格、分区等)内部 DTO |
#### Dtos/Recharge/
@@ -360,15 +364,16 @@ JWT 中业务用户整数 ID 的 Claim 名为 **`mapId`**(对应 `user.userid`
| `Entities/OrderFee.cs` | 订单费用明细 |
| `Entities/OrderWeight.cs` | 重量尺寸(预报/复核等) |
| `Entities/OrderTemplate.cs` | 导入模板元数据 |
-| `Entities/OrderTask.cs` | 批量导入任务 |
-| `Entities/OrderTaskFail.cs` | 导入失败行 |
+| `Entities/OrderBatchJob.cs` | 批量任务头(导入/支付/打印等) |
+| `Entities/OrderBatchJobItem.cs` | 批量任务子项 |
+| `Consts/OrderBatchJobStatusCodes.cs` | 批量任务状态常量 |
| `Consts/OrderStatusCodes.cs` | 订单状态常量 |
| `Consts/OrderFeeTypeCodes.cs` | 费用类型常量 |
| `Consts/OrderWeightTypeCodes.cs` | 重量类型常量 |
| `Consts/CargoAttributeCodes.cs` | 货物属性常量 |
| `Repositories/IOrderRepository.cs` | 订单仓储(含关联加载) |
| `Repositories/IOrderTemplateRepository.cs` | 模板仓储 |
-| `Repositories/IOrderTaskRepository.cs` | 导入任务仓储 |
+| `Repositories/IOrderBatchJobRepository.cs` | 批量任务仓储 |
### ChannelsAggregate/(渠道/产品/计价)
@@ -449,7 +454,7 @@ JWT 中业务用户整数 ID 的 Claim 名为 **`mapId`**(对应 `user.userid`
| `Recharge/RechargeRecordRepository.cs` | `IRechargeRecordRepository` | 充值记录 |
| `Orders/OrderRepository.cs` | `IOrderRepository` | 订单及关联表 |
| `Orders/OrderTemplateRepository.cs` | `IOrderTemplateRepository` | 订单模板 |
-| `Orders/OrderTaskRepository.cs` | `IOrderTaskRepository` | 导入任务 |
+| `Orders/OrderBatchJobRepository.cs` | `IOrderBatchJobRepository` | 批量任务及子项 |
| `Channels/SalesProductRepository.cs` | `ISalesProductRepository` | 销售产品 |
| `Channels/ServiceChannelRepository.cs` | `IServiceChannelRepository` | 服务渠道 |
| `Channels/ServiceChannelGroupRepository.cs` | `IServiceChannelGroupRepository` | 渠道分组 |