admin/src/Oms.Application/Services/Orders/OrderService.cs
zzh 377cc525d6 feat: 增强批量任务子项反馈与客户单号持久化
统一成功/失败结果快照,补全客户单号与 Excel 行号;系统重试耗尽后标记失败避免子项长期停留在执行中;任务 API 返回 pending/running/cancelled 计数。
2026-07-22 12:00:53 +08:00

558 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Fake.Application;
using Fake.Domain.Exceptions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using MiniExcelLibs;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.DataPerm;
using Oms.Application.Labeling;
using Oms.Application.Shared;
using Newtonsoft.Json;
using Oms.Application.Services.Orders.Batch;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 订单相关 API 服务。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Order")]
public partial class OrderService : ApplicationService
{
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService<IOrderRepository>();
private IOrderTemplateRepository OrderTemplateRepository => LazyServiceProvider.GetRequiredService<IOrderTemplateRepository>();
private IOrderTaskRepository OrderTaskRepository => LazyServiceProvider.GetRequiredService<IOrderTaskRepository>();
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
private LabelPdfGenerationService LabelPdfGenerationService =>
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();
private EffectiveScopeBuilder DataScopeBuilder =>
LazyServiceProvider.GetRequiredService<EffectiveScopeBuilder>();
/// <summary>
/// 查询订单列表。
/// </summary>
/// <param name="input">入参:订单分页查询条件。</param>
/// <returns>订单分页结果,包含列表数据和总记录数。</returns>
[HttpPost("/order/list")]
public async Task<OmsPageResult<OrderOutput>> GetPaginatedListAsync([FromBody] OrderPaginatedInput input)
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
var filter = BuildListFilter(input, customerId);
await ApplyInternalOrderDataScopeAsync(filter);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var (orders, total) = await OrderRepository.GetFilteredPagedListAsync(
pageIndex,
pageSize,
filter);
return new OmsPageResult<OrderOutput> { Items = await MapOrderOutputsAsync(orders), Total = total };
}
/// <summary>
/// 按状态聚合统计订单数量(单次查询,供 Tab 角标展示)。
/// </summary>
[HttpGet("/order/status-counts")]
public async Task<OrderStatusCountsOutput> GetStatusCountsAsync()
{
var filter = await BuildScopedListFilterAsync();
var rows = await OrderRepository.GetStatusCountsByFilterAsync(filter);
var counts = rows.ToDictionary(
x => x.Status,
x => x.Count,
StringComparer.OrdinalIgnoreCase);
var handleRows = await OrderRepository.GetHandleStatusCountsByFilterAsync(filter);
var handleCounts = handleRows.ToDictionary(
x => x.Key,
x => new OrderHandleStatusCountsOutput
{
Pending = x.Value.Pending,
Processed = x.Value.Processed
},
StringComparer.OrdinalIgnoreCase);
return new OrderStatusCountsOutput
{
Total = rows.Sum(x => x.Count),
Counts = counts,
HandleCounts = handleCounts
};
}
/// <summary>
/// 查询订单详情。
/// </summary>
/// <param name="id">入参:目标主键。</param>
/// <returns>订单详情。</returns>
[HttpGet("/order/detail")]
public async Task<OrderOutput> GetDetailAsync([FromQuery] int id)
{
var order = await GetOrderOrThrowAsync(id);
return (await MapOrderOutputsAsync([order])).First();
}
/// <summary>
/// 执行订单支付。
/// </summary>
/// <param name="input">入参:订单操作参数。</param>
/// <returns>无返回体,仅完成订单支付处理。</returns>
[HttpPost("/order/pay")]
public async Task PayAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
if (order.IsPaid > 0)
{
throw new BusinessException("订单已支付");
}
if (order.OrderStatus is not (OrderStatusCodes.PendingPayment or OrderStatusCodes.Received))
{
throw new BusinessException("当前订单状态不可支付");
}
if (!order.CustomerId.HasValue)
{
throw new BusinessException("订单未关联客户,无法支付");
}
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
var payAmount = fees
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
.Sum(x => x.Amount);
if (payAmount <= 0)
{
throw new BusinessException("订单应收金额无效,无法支付");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
var balanceMutator = LazyServiceProvider.GetRequiredService<CustomerBalanceMutator>();
await balanceMutator.DebitOrderPayAsync(
customer,
payAmount,
order.Id,
order.ServiceNo,
$"订单支付 {order.ServiceNo}");
order.PayTime = DateTime.Now;
order.IsPaid = 1;
order.OrderStatus = OrderStatusCodes.PendingShip;
await UpdateOrderAsync(order);
}
/// <summary>
/// 执行订单预报。
/// </summary>
/// <param name="input">入参:订单操作参数。</param>
/// <returns>无返回体,仅完成订单预报处理。</returns>
[HttpPost("/order/pre-declare")]
public async Task PreDeclareAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
order.OrderStatus = OrderStatusCodes.Predeclared;
await UpdateOrderAsync(order);
}
/// <summary>
/// 执行订单拦截。
/// </summary>
/// <param name="input">入参:订单操作参数。</param>
/// <returns>无返回体,仅完成订单拦截处理。</returns>
[HttpPost("/order/intercept")]
public async Task InterceptAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
order.IsIntercept = 1;
await UpdateOrderAsync(order);
}
/// <summary>
/// 取消订单拦截。
/// </summary>
/// <param name="input">入参:订单操作参数。</param>
/// <returns>无返回体,仅完成取消订单拦截。</returns>
[HttpPost("/order/cancel-intercept")]
public async Task CancelInterceptAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
order.IsIntercept = 0;
await UpdateOrderAsync(order);
}
/// <summary>
/// 删除订单。
/// </summary>
/// <param name="input">入参:订单操作参数。</param>
/// <returns>无返回体,仅完成删除订单。</returns>
[HttpPost("/order/delete")]
public async Task DeleteAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
if (order.OrderStatus is not (OrderStatusCodes.Draft or OrderStatusCodes.Predeclared))
{
throw new BusinessException("仅草稿或已预报订单可删除");
}
order.OrderStatus = OrderStatusCodes.Deleted;
await OrderRepository.UpdateAsync(order);
await OrderRepository.DeleteAsync(order);
}
/// <summary>
/// 打印订单。
/// </summary>
/// <param name="input">入参:订单操作参数。</param>
/// <returns>订单打印结果。</returns>
[HttpPost("/order/print")]
public async Task<OrderPrintOutput> PrintAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
var mapped = (await MapOrderOutputsAsync([order])).First();
if (string.Equals(order.LabelGenerateStatus, LabelGenerateStatusCodes.Ready, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(order.LabelUrl))
{
return new OrderPrintOutput
{
PrintUrl = order.LabelUrl!,
LabelGenerateStatus = order.LabelGenerateStatus,
LabelGenerateError = order.LabelGenerateError
};
}
// Pending / Failed / 无状态:即时补生成(打印场景优先出 PDF)
var result = await LabelPdfGenerationService.GenerateAndPersistAsync(
order,
mapped,
templateIdOverride: null,
operatorName: CurrentUser.UserName);
if (!result.Success || string.IsNullOrWhiteSpace(result.LabelUrl))
{
// 兼容回落:仍无模板时给 HTML stub,避免彻底不可打
var fallbackUrl = await CreateOrderPrintFileAsync(mapped);
return new OrderPrintOutput
{
PrintUrl = fallbackUrl,
LabelGenerateStatus = result.Status,
LabelGenerateError = result.Error ?? "面单 PDF 未就绪,已回落 HTML"
};
}
return new OrderPrintOutput
{
PrintUrl = result.LabelUrl!,
LabelGenerateStatus = result.Status,
LabelGenerateError = result.Error
};
}
/// <summary>
/// 导出订单。
/// </summary>
/// <param name="input">入参:订单导出筛选条件。</param>
/// <returns>订单导出结果。</returns>
[HttpPost("/order/export")]
public async Task<OrderExportOutput> ExportAsync([FromBody] OrderExportInput input)
{
await CleanupExpiredFilesAsync(GetOrderExportDirectory());
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
null);
var filter = BuildListFilter(input, customerId);
await ApplyInternalOrderDataScopeAsync(filter);
var orders = await OrderRepository.GetFilteredListAsync(filter);
var fileName = $"orders-{DateTime.Now:yyyyMMddHHmmss}.xlsx";
var directory = GetOrderExportDirectory();
Directory.CreateDirectory(directory);
await MiniExcel.SaveAsAsync(Path.Combine(directory, fileName), await BuildOrderExportRowsAsync(orders));
return new OrderExportOutput
{
DownloadUrl = $"/uploads/exports/orders/{fileName}",
ExpireAt = DateTime.Now.AddHours(24)
};
}
/// <summary>
/// 查价运输方式选项(按销售产品分组)。
/// </summary>
[HttpGet("/order/price-check/shipping-options")]
public Task<OrderPriceCheckShippingOptionsOutput> GetPriceCheckShippingOptionsAsync() =>
BuildPriceCheckShippingOptionsAsync();
/// <summary>
/// 执行订单运费试算。
/// </summary>
/// <param name="input">入参:订单运费试算参数。</param>
/// <returns>订单运费试算列表结果。</returns>
[HttpPost("/order/price-check")]
public async Task<OrderPriceCheckResultOutput> PriceCheckAsync(OrderPriceCheckInput input)
{
return await BuildPriceCheckResultAsync(input);
}
/// <summary>
/// 查询订单模板列表。
/// </summary>
/// <param name="input">入参:订单模板列表查询条件。</param>
/// <returns>订单模板分页结果,包含列表数据和总记录数。</returns>
[HttpGet("/order/template/list")]
public async Task<OmsPageResult<OrderTemplateOutput>> GetTemplateListAsync([FromQuery] OrderTemplateListInput input)
{
var items = await GetTemplateOutputsAsync(input);
return items;
}
/// <summary>
/// 下载订单模板文件。
/// </summary>
/// <param name="templateId">入参:模板主键。</param>
/// <returns>文件流响应。</returns>
[HttpGet("/order/template/download")]
public async Task<FileContentResult> DownloadTemplateAsync([FromQuery] int templateId)
{
return await BuildTemplateDownloadAsync(templateId);
}
/// <summary>
/// 导入订单文件。
/// </summary>
/// <param name="input">入参:订单导入参数。</param>
/// <returns>订单导入结果。</returns>
[HttpPost("/order/upload")]
public async Task<OrderUploadOutput> UploadAsync([FromForm] OrderUploadInput input)
{
return await ImportOrdersAsync(input);
}
/// <summary>
/// 查询订单任务列表。
/// </summary>
/// <param name="input">入参:订单任务列表查询条件。</param>
/// <returns>订单任务分页结果,包含列表数据和总记录数。</returns>
[HttpGet("/order/task/list")]
public async Task<OmsPageResult<OrderTaskOutput>> GetTaskListAsync([FromQuery] OrderTaskListInput input)
{
return await GetTaskOutputsAsync(input);
}
/// <summary>
/// 查询订单任务详情。
/// </summary>
/// <param name="taskId">入参:任务主键。</param>
/// <returns>订单任务详情。</returns>
[HttpGet("/order/task/detail")]
public async Task<OrderTaskDetailOutput> GetTaskDetailAsync([FromQuery] int taskId)
{
return await GetTaskDetailCoreAsync(taskId);
}
/// <summary>
/// 创建订单包裹。
/// </summary>
/// <param name="input">入参:订单包裹创建参数。</param>
/// <returns>订单包裹创建结果。</returns>
[HttpPost("/order/parcel/create")]
public async Task<OrderParcelCreateOutput> CreateParcelAsync(OrderParcelCreateInput input)
{
return await CreateParcelCoreAsync(input);
}
/// <summary>
/// 批量创建订单包裹。
/// </summary>
/// <param name="input">入参:批量创建小包请求集合,每条记录沿用单票创建结构。</param>
/// <returns>批量处理结果,包含批次号、成功失败数量与逐条执行明细。</returns>
[HttpPost("/order/parcel/batch-create")]
public async Task<OrderParcelBatchCreateOutput> BatchCreateParcelAsync(OrderParcelBatchCreateInput input)
{
if (input == null || input.Count == 0)
{
throw new BusinessException("批量创建请求不能为空");
}
if (input.Count > ParcelBatchMaxCount)
{
throw new BusinessException($"单次批量创建不能超过{ParcelBatchMaxCount}条");
}
if (BatchJobPublisher.IsEnabled)
{
return await BatchCreateParcelViaQueueAsync(input);
}
return await BatchCreateParcelSyncAsync(input);
}
private async Task<OrderParcelBatchCreateOutput> BatchCreateParcelViaQueueAsync(OrderParcelBatchCreateInput input)
{
var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}";
var operatorUserId = CurrentUser.GetId<int>();
var payloadItems = input.Select((request, index) => new OrderBatchPayloadItemInput
{
OrderId = 0,
ServiceNo = request.ServiceNo,
ClientNo = request.ClientNo,
ItemKey = !string.IsNullOrWhiteSpace(request.RequestId)
? $"parcel:{request.RequestId}"
: $"parcel:{batchNo}:{index + 1}",
PayloadJson = JsonConvert.SerializeObject(request),
}).ToList();
var job = await BatchJobCoordinator.CreatePayloadJobAsync(
new OrderBatchPayloadJobCreateInput
{
Action = OrderBatchActionCodes.ParcelCreate,
RequestId = batchNo,
Items = payloadItems,
},
operatorUserId);
return new OrderParcelBatchCreateOutput
{
BatchNo = batchNo,
JobId = job.JobId,
Async = true,
Total = input.Count,
SuccessCount = 0,
FailCount = 0,
Items = [],
};
}
private async Task<OrderParcelBatchCreateOutput> BatchCreateParcelSyncAsync(OrderParcelBatchCreateInput input)
{
var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}";
var items = new List<OrderParcelBatchCreateItemOutput>(input.Count);
var successCount = 0;
for (var index = 0; index < input.Count; index++)
{
var request = input[index];
try
{
var output = await CreateParcelCoreAsync(request);
successCount++;
items.Add(new OrderParcelBatchCreateItemOutput
{
Index = index + 1,
RequestId = request.RequestId,
ClientNo = output.ClientNo ?? request.ClientNo,
ServiceNo = output.ServiceNo ?? request.ServiceNo,
TrackNo = output.TrackNo ?? request.TrackNo,
LabelUrl = output.LabelUrl,
Success = true,
Message = "success"
});
}
catch (Exception ex)
{
items.Add(new OrderParcelBatchCreateItemOutput
{
Index = index + 1,
RequestId = request.RequestId,
ClientNo = request.ClientNo,
ServiceNo = request.ServiceNo,
TrackNo = request.TrackNo,
Success = false,
Message = ex.Message
});
}
}
return new OrderParcelBatchCreateOutput
{
BatchNo = batchNo,
SuccessCount = successCount,
FailCount = items.Count - successCount,
Items = items
};
}
private static OrderListFilter BuildListFilter(OrderPaginatedInput input, int? customerId) =>
new()
{
Status = input.Status,
HandleStatus = input.HandleStatus,
OrderSource = input.OrderSource,
Keyword = input.Keyword,
CreateTimeStart = input.CreateTimeStart,
CreateTimeEnd = input.CreateTimeEnd,
CustomerId = customerId
};
private static OrderListFilter BuildListFilter(OrderExportInput input, int? customerId) =>
new()
{
Status = input.Status,
OrderSource = input.OrderSource,
Keyword = input.Keyword,
CreateTimeStart = input.CreateTimeStart,
CreateTimeEnd = input.CreateTimeEnd,
CustomerId = customerId
};
private async Task EnsureCustomerCanAccessOrderAsync(Order order)
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
null);
if (!customerId.HasValue)
{
return;
}
if (order.CustomerId == customerId.Value)
{
return;
}
// 兼容历史脏数据:下单人是当前客户账号,但订单 CustomerId 未写对
var mapId = OmsSessionHelper.TryGetCustomerMapId(CurrentUser);
if (mapId is > 0 && order.CreateUserId == mapId.Value)
{
return;
}
throw new BusinessException("订单不存在");
}
private async Task<Order> GetOrderOrThrowAsync(int id)
{
var entity = await OrderRepository.FirstOrDefaultAsync(x => x.Id == id);
if (entity == null)
{
throw new BusinessException("订单不存在");
}
await EnsureCustomerCanAccessOrderAsync(entity);
return entity;
}
private async Task UpdateOrderAsync(Order entity)
{
await OrderRepository.UpdateAsync(entity);
}
}