admin/src/Oms.Application/Services/Orders/OrderService.cs
xiaolipro 7b69bbf207
All checks were successful
Build and Deploy / deploy (push) Successful in 1m4s
test
2026-10-14 17:07:35 +08:00

1211 lines
44 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 System.Data;
using System.Globalization;
using Fake.Application;
using Fake.Domain.Exceptions;
using Fake.ObjectStorage;
using Fake.UnitOfWork;
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 Oms.Application.Storage;
using Newtonsoft.Json;
using Oms.Application.Messaging.ParcelCreate;
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 IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService<IPickupOrderRepository>();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService<IObjectStorage>();
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(
[FromQuery] DateTime? createTimeStart = null,
[FromQuery] DateTime? createTimeEnd = null,
[FromQuery] string? fulfillmentType = null)
{
var filter = await BuildScopedListFilterAsync();
filter.CreateTimeStart = createTimeStart;
filter.CreateTimeEnd = createTimeEnd;
filter.FulfillmentType = NormalizeFulfillmentTypeFilter(fulfillmentType);
EnsureOrderListTimeWindow(filter);
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);
var output = new OrderStatusCountsOutput
{
Total = rows.Sum(x => x.Count),
Counts = counts,
HandleCounts = handleCounts
};
if (string.Equals(filter.FulfillmentType, FulfillmentTypeCodes.Warehouse, StringComparison.OrdinalIgnoreCase))
{
output.FulfillProgressCounts = await OrderRepository.GetFulfillProgressCountsByFilterAsync(filter);
}
return output;
}
/// <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)
{
throw new BusinessException("当前订单状态不可支付");
}
if (!order.CustomerId.HasValue)
{
throw new BusinessException("订单未关联客户,无法支付");
}
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order);
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
var payAmount = order.PayableAmount ?? 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}");
if (order.CouponInstanceId.HasValue)
{
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id);
await couponManager.MarkUsedAsync(order.CouponInstanceId.Value, mainCustomerId, order.Id);
}
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer);
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight(
MarketingFeeHelper.SumReceivableBaseFreight(fees),
order.LevelDiscountAmount,
order.CouponDiscountAmount);
await pointsManager.EarnOnOrderPaidAsync(mainCustomer.Id, order.Id, paidBaseFreight, order.ServiceNo);
order.PayTime = DateTime.Now;
order.IsPaid = 1;
order.OrderStatus = OrderStatusCodes.PendingShip;
await UpdateOrderAsync(order);
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order);
var amountText = payAmount.ToString("0.####", CultureInfo.InvariantCulture);
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.Pay,
beforeSnap,
afterSnap,
$"支付金额 {amountText}");
}
/// <summary>
/// 标记订单派送成功(触发引荐分成 Pending)。
/// </summary>
[HttpPost("/order/mark-delivery-success")]
public async Task MarkDeliverySuccessAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
order.OrderStatus = OrderStatusCodes.DeliverySuccess;
order.SignTime = DateTime.Now;
await UpdateOrderAsync(order);
var commissionManager = LazyServiceProvider.GetRequiredService<ReferralCommissionManager>();
await commissionManager.OnOrderDeliverySuccessAsync(order.Id);
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
await couponManager.OnFirstDeliverySuccessAsync(order);
}
/// <summary>
/// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。
/// </summary>
[HttpPost("/order/refund")]
public async Task RefundAsync(OrderRefundInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
if (order.IsPaid == 0)
{
throw new BusinessException("订单未支付,无需退款");
}
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
await couponManager.RestoreOnOrderRefundAsync(order, input.IsFullRefund);
order.IsRefunded = 1;
await UpdateOrderAsync(order);
var commissionManager = LazyServiceProvider.GetRequiredService<ReferralCommissionManager>();
await commissionManager.ClawbackOnOrderRefundAsync(order);
if (input.IsFullRefund && order.CustomerId.HasValue)
{
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer != null)
{
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
CustomerRepository, customer);
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
await pointsManager.ClawbackOnOrderAsync(
mainCustomer.Id,
order.Id,
order.ServiceNo,
"订单退款扣回积分");
}
}
}
/// <summary>
/// 执行订单预报。
/// </summary>
/// <param name="input">入参:订单操作参数。</param>
/// <returns>无返回体,仅完成订单预报处理。</returns>
[HttpPost("/order/pre-declare")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public async Task PreDeclareAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
if (string.Equals(order.OrderStatus, OrderStatusCodes.Predeclared, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!string.Equals(order.OrderStatus, OrderStatusCodes.Draft, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(
$"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许预报");
}
await CompleteDraftPredeclareAsync(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;
if (order.IsPaid > 0 && order.CustomerId.HasValue)
{
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer != null)
{
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
CustomerRepository,
customer);
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
await pointsManager.ClawbackOnOrderAsync(
mainCustomer.Id,
order.Id,
order.ServiceNo,
"订单删除扣回积分");
}
}
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();
var existingUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id);
if (!string.IsNullOrWhiteSpace(existingUrl))
{
await OperationLogWriter.TryWriteSimpleAsync(
order,
OrderOperationActionCodes.Print,
"打印面单(使用已有文件)");
return new OrderPrintOutput
{
PrintUrl = existingUrl!,
LabelGenerateStatus = LabelGenerateStatusCodes.Ready
};
}
// 无系统面单:即时补生成(打印场景优先出 PDF)
var result = await LabelPdfGenerationService.GenerateAndPersistAsync(
order,
mapped,
templateIdOverride: null,
operatorName: CurrentUser.UserName,
operatorUserId: CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
if (!result.Success || string.IsNullOrWhiteSpace(result.LabelUrl))
{
// 兼容回落:仍无模板时给 HTML stub,避免彻底不可打
var fallbackUrl = await CreateOrderPrintFileAsync(mapped);
await OperationLogWriter.TryWriteSimpleAsync(
order,
OrderOperationActionCodes.Print,
"打印面单(回落 HTML)");
return new OrderPrintOutput
{
PrintUrl = fallbackUrl,
LabelGenerateStatus = result.Status,
LabelGenerateError = result.Error ?? "面单 PDF 未就绪,已回落 HTML"
};
}
await OperationLogWriter.TryWriteSimpleAsync(
order,
OrderOperationActionCodes.Print,
"打印面单");
return new OrderPrintOutput
{
PrintUrl = result.LabelUrl!,
LabelGenerateStatus = result.Status,
LabelGenerateError = result.Error
};
}
/// <summary>
/// 批量获取打印面单地址(同步返回,避免前端逐单请求)。
/// </summary>
/// <param name="input">入参:订单 ID 列表。</param>
/// <returns>各订单打印结果。</returns>
[HttpPost("/order/print/batch")]
public async Task<OrderPrintBatchOutput> PrintBatchAsync([FromBody] OrderPrintBatchInput input)
{
var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList();
if (orderIds.Count == 0)
{
throw new BusinessException("请选择要打印的订单");
}
const int maxBatchSize = 100;
if (orderIds.Count > maxBatchSize)
{
throw new BusinessException($"单次最多获取 {maxBatchSize} 张面单");
}
var output = new OrderPrintBatchOutput
{
Items = new List<OrderPrintBatchItemOutput>(orderIds.Count)
};
// 已有系统面单一律批量查出,避免 N 次附件查询
var existingUrls = await OrderFileManager.GetSystemLabelUrlsAsync(orderIds);
var pendingIds = new List<int>();
foreach (var orderId in orderIds)
{
if (existingUrls.TryGetValue(orderId, out var url) && !string.IsNullOrWhiteSpace(url))
{
output.Items.Add(new OrderPrintBatchItemOutput
{
OrderId = orderId,
Success = true,
PrintUrl = url,
LabelGenerateStatus = LabelGenerateStatusCodes.Ready
});
try
{
var existingOrder = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
if (existingOrder != null)
{
await OperationLogWriter.TryWriteSimpleAsync(
existingOrder,
OrderOperationActionCodes.Print,
"批量打印(使用已有文件)");
}
}
catch
{
// 日志失败不影响打印
}
}
else
{
pendingIds.Add(orderId);
}
}
// 未就绪的走单票打印逻辑(含即时补生成),单条失败不影响其它
foreach (var orderId in pendingIds)
{
try
{
var print = await PrintAsync(new OrderActionInput { Id = orderId });
var ok = !string.IsNullOrWhiteSpace(print.PrintUrl);
output.Items.Add(new OrderPrintBatchItemOutput
{
OrderId = orderId,
Success = ok,
PrintUrl = print.PrintUrl ?? string.Empty,
LabelGenerateStatus = print.LabelGenerateStatus,
Error = ok ? null : (print.LabelGenerateError ?? "未获取到打印文件")
});
}
catch (Exception ex)
{
output.Items.Add(new OrderPrintBatchItemOutput
{
OrderId = orderId,
Success = false,
Error = ex is BusinessException be ? be.Message : ex.Message
});
}
}
// 保持与入参顺序一致
var byId = output.Items.ToDictionary(x => x.OrderId);
output.Items = orderIds
.Select(id => byId.TryGetValue(id, out var item)
? item
: new OrderPrintBatchItemOutput { OrderId = id, Success = false, Error = "未处理" })
.ToList();
return output;
}
/// <summary>
/// 导出订单。
/// </summary>
/// <param name="input">入参:订单导出筛选条件。</param>
/// <returns>订单导出结果。</returns>
[HttpPost("/order/export")]
public async Task<OrderExportOutput> ExportAsync([FromBody] OrderExportInput input)
{
await CleanupExpiredLocalExportFilesAsync();
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
null);
var filter = BuildListFilter(input, customerId);
await ApplyInternalOrderDataScopeAsync(filter);
const int maxExportRows = 10000;
var (orders, total) = await OrderRepository.GetFilteredPagedListAsync(1, maxExportRows, filter);
if (total > maxExportRows)
{
throw new BusinessException($"导出结果超过 {maxExportRows} 条(当前 {total}),请缩小筛选条件后再导出");
}
var fileName = $"orders-{DateTime.Now:yyyyMMddHHmmss}.xlsx";
var relativeKey = $"uploads/exports/orders/{fileName}";
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders));
stream.Position = 0;
var storageKey = await FileStorage.SaveObjectAsync(
stream,
relativeKey,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
return new OrderExportOutput
{
DownloadUrl = FileStorage.GetPublicUrl(storageKey),
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)
{
if (!input.CustomerId.HasValue || input.CustomerId.Value <= 0)
{
input.CustomerId = await ResolvePriceCheckCustomerIdAsync();
}
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>
/// 导入订单文件,创建 import-row 批量任务并返回 jobId。
/// </summary>
/// <param name="input">入参:订单导入参数。</param>
/// <returns>批量任务 ID 及是否异步处理。</returns>
[HttpPost("/order/upload")]
public async Task<OrderUploadOutput> UploadAsync([FromForm] OrderUploadInput input)
{
return await ImportOrdersAsync(input);
}
/// <summary>
/// 创建订单包裹。
/// </summary>
/// <param name="input">入参:订单包裹创建参数。</param>
/// <returns>订单包裹创建结果。</returns>
[HttpPost("/order/parcel/create")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public async Task<OrderParcelCreateOutput> CreateParcelAsync(OrderParcelCreateInput input)
{
return await CreateParcelCoreAsync(input, createContext: BuildParcelCreateContext(input));
}
/// <summary>
/// 客户 OpenAPI 创单:同一客户相同 clientNo 视为幂等,返回已存在订单,不新建。
/// </summary>
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public async Task<OrderParcelCreateOutput> CreateParcelForCustomerOpenApiAsync(OrderParcelCreateInput input)
{
NormalizeParcelCreateInput(input);
if (!string.IsNullOrWhiteSpace(input.ClientNo) && input.CustomerId is > 0)
{
var existing = await OrderRepository.GetByCustomerAndClientNoAsync(
input.CustomerId.Value,
input.ClientNo);
if (existing != null)
{
return await BuildParcelCreateOutputAsync(existing);
}
}
try
{
return await CreateParcelAsync(input);
}
catch (BusinessException ex) when (ex.Message == "客户单号已存在")
{
if (string.IsNullOrWhiteSpace(input.ClientNo) || input.CustomerId is not > 0)
{
throw;
}
var raced = await OrderRepository.GetByCustomerAndClientNoAsync(
input.CustomerId.Value,
input.ClientNo);
if (raced == null)
{
throw;
}
return await BuildParcelCreateOutputAsync(raced);
}
}
private async Task TryAttachPickupAfterParcelCreateAsync(
OrderParcelCreateInput input,
OrderParcelCreateOutput output)
{
if (output.OrderId <= 0)
{
return;
}
var inbound = string.IsNullOrWhiteSpace(input.InboundMethod)
? null
: input.InboundMethod.Trim();
if (!string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (input.SenderAddressId is not > 0)
{
output.PickupCreateError = "上门揽收需选择揽收地址";
return;
}
if (input.ExpectPickupTime is null || input.ExpectPickupTime == default)
{
output.PickupCreateError = "上门揽收需填写期望揽收时间";
return;
}
if (input.WarehouseId is not > 0)
{
output.PickupCreateError = "上门揽收需选择仓库";
return;
}
var publisher = LazyServiceProvider.GetRequiredService<IOrderParcelCreateEventPublisher>();
try
{
// 优先入 Fake.EventBus;发布失败则同步回落建揽收并回写单号。
var queued = await publisher.PublishPickupAttachAsync(input, output.OrderId);
if (queued)
{
output.PickupQueued = true;
return;
}
var pickup = await publisher.ExecutePickupAttachAsync(input, output.OrderId);
if (pickup == null)
{
output.PickupCreateError = "揽收单创建失败";
return;
}
output.PickupNo = pickup.PickupNo;
output.PickupOrderId = pickup.Id;
output.CarrierWaybillNo = pickup.CarrierWaybillNo;
}
catch (Exception ex)
{
output.PickupCreateError = ex.Message;
}
}
/// <summary>
/// 批量创建订单包裹。
/// </summary>
/// <param name="input">入参:批量创建小包请求集合,每条记录沿用单票创建结构。</param>
/// <returns>批量处理结果,包含批次号、成功失败数量与逐条执行明细。</returns>
[HttpPost("/order/parcel/batch-create")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
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)
{
var filter = new OrderListFilter
{
Status = input.Status,
HandleStatus = input.HandleStatus,
OrderSource = input.OrderSource,
FulfillmentType = NormalizeFulfillmentTypeFilter(input.FulfillmentType),
StockBindStatus = NormalizeStockBindStatusFilter(input.StockBindStatus),
FulfillProgress = NormalizeFulfillProgressFilter(input.FulfillProgress),
Keyword = NormalizeOrderKeyword(input.Keyword),
CreateTimeStart = input.CreateTimeStart,
CreateTimeEnd = input.CreateTimeEnd,
CustomerId = customerId,
WarehouseId = NormalizeOrderWarehouseId(input.WarehouseId),
FieldFilters = NormalizeOrderFieldFilters(input.Filters),
SortField = NormalizeOrderSortField(input.SortField),
SortOrder = NormalizeOrderSortOrder(input.SortOrder)
};
EnsureOrderListTimeWindow(filter);
return filter;
}
private static OrderListFilter BuildListFilter(OrderExportInput input, int? customerId)
{
var filter = new OrderListFilter
{
Status = input.Status,
OrderSource = input.OrderSource,
FulfillmentType = NormalizeFulfillmentTypeFilter(input.FulfillmentType),
FulfillProgress = NormalizeFulfillProgressFilter(input.FulfillProgress),
Keyword = NormalizeOrderKeyword(input.Keyword),
CreateTimeStart = input.CreateTimeStart,
CreateTimeEnd = input.CreateTimeEnd,
CustomerId = customerId,
FieldFilters = NormalizeOrderFieldFilters(input.Filters),
SortField = NormalizeOrderSortField(input.SortField),
SortOrder = NormalizeOrderSortOrder(input.SortOrder)
};
EnsureOrderListTimeWindow(filter);
return filter;
}
/// <summary>订单列表默认时间窗(天)。无精确单号且未传时间时兜底,防全表扫。</summary>
private const int OrderListDefaultDays = 90;
/// <summary>
/// 无创建时间且非「精确单号查询」时,自动限制近 N 天。
/// </summary>
private static void EnsureOrderListTimeWindow(OrderListFilter filter)
{
if (filter.CreateTimeStart.HasValue || filter.CreateTimeEnd.HasValue)
{
return;
}
if (HasNarrowOrderLookup(filter))
{
return;
}
filter.CreateTimeStart = DateTime.Now.Date.AddDays(-(OrderListDefaultDays - 1));
}
private static bool HasNarrowOrderLookup(OrderListFilter filter)
{
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
return true;
}
if (filter.FieldFilters == null || filter.FieldFilters.Count == 0)
{
return false;
}
var numberFields = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"serviceNo", "clientNo", "trackNo", "downstreamServiceNo"
};
return filter.FieldFilters.Any(f =>
numberFields.Contains(f.Field)
&& (f.Op is "eq" or "in" or "like")
&& (!string.IsNullOrWhiteSpace(f.Value) || (f.Values?.Count ?? 0) > 0));
}
private static readonly HashSet<string> OrderFieldFilterWhitelist = new(StringComparer.OrdinalIgnoreCase)
{
"serviceNo",
"clientNo",
"trackNo",
"downstreamServiceNo",
"createTime",
"orderStatus",
"productCode",
"channelCode",
"orderSource",
"fulfillmentType",
"payableAmount",
"isPaid",
"predeclareFailReason",
"allocationPendingReason",
"payTime",
"shipTime",
"receiveTime",
"signTime"
};
private static readonly HashSet<string> OrderFieldFilterOps = new(StringComparer.OrdinalIgnoreCase)
{
"like", "eq", "ne", "in", "notIn", "gt", "gte", "lt", "lte", "between",
"empty", "isNull", "isNotNull"
};
private static readonly HashSet<string> OrderSortFieldWhitelist = new(StringComparer.OrdinalIgnoreCase)
{
"createTime",
"updateTime",
"serviceNo",
"clientNo",
"trackNo",
"primaryBinCode",
"payableAmount",
"payTime",
"shipTime",
"receiveTime",
"signTime"
};
/// <summary>单次列表最多接受的 filters 条数。</summary>
private const int MaxOrderFieldFilters = 20;
/// <summary>IN / NOT IN 单字段最多值数量(防几万单号打爆 SQL/参数包)。</summary>
private const int MaxOrderInValues = 500;
/// <summary>单值最大长度(单号/文本)。</summary>
private const int MaxOrderFilterValueLength = 128;
/// <summary>keyword 最大长度。</summary>
private const int MaxOrderKeywordLength = 200;
private static string? NormalizeOrderKeyword(string? keyword)
{
var text = keyword?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return text.Length <= MaxOrderKeywordLength
? text
: text[..MaxOrderKeywordLength];
}
private static string? TruncateFilterValue(string? value)
{
var text = value?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return text.Length <= MaxOrderFilterValueLength
? text
: text[..MaxOrderFilterValueLength];
}
private static string? NormalizeOrderSortField(string? sortField)
{
var field = sortField?.Trim();
if (string.IsNullOrWhiteSpace(field) || !OrderSortFieldWhitelist.Contains(field))
{
return null;
}
return field;
}
private static string? NormalizeOrderSortOrder(string? sortOrder)
{
var order = sortOrder?.Trim();
if (string.IsNullOrWhiteSpace(order))
{
return null;
}
if (string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase))
{
return "asc";
}
if (string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase))
{
return "desc";
}
return null;
}
private static string? NormalizeFulfillmentTypeFilter(string? fulfillmentType)
{
var code = fulfillmentType?.Trim();
if (string.IsNullOrWhiteSpace(code)
|| string.Equals(code, "all", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (!FulfillmentTypeCodes.All.Contains(code))
{
throw new BusinessException($"不支持的履约类型:{fulfillmentType}");
}
return code;
}
private static string? NormalizeStockBindStatusFilter(string? stockBindStatus)
{
var code = stockBindStatus?.Trim();
if (string.IsNullOrWhiteSpace(code)
|| string.Equals(code, "all", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (string.Equals(code, "none", StringComparison.OrdinalIgnoreCase)
|| string.Equals(code, OrderStockBindStatusCodes.Bound, StringComparison.OrdinalIgnoreCase)
|| string.Equals(code, OrderStockBindStatusCodes.Deducted, StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(code, "none", StringComparison.OrdinalIgnoreCase))
{
return "none";
}
if (string.Equals(code, OrderStockBindStatusCodes.Bound, StringComparison.OrdinalIgnoreCase))
{
return OrderStockBindStatusCodes.Bound;
}
return OrderStockBindStatusCodes.Deducted;
}
throw new BusinessException($"不支持的绑定状态筛选:{stockBindStatus}");
}
private static int? NormalizeOrderWarehouseId(int? warehouseId) =>
warehouseId is > 0 ? warehouseId : null;
private static string? NormalizeFulfillProgressFilter(string? fulfillProgress)
{
var code = fulfillProgress?.Trim();
if (string.IsNullOrWhiteSpace(code)
|| string.Equals(code, "all", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (FulfillmentProgressCodes.All.Contains(code))
{
return code;
}
throw new BusinessException($"不支持的代发进度筛选:{fulfillProgress}");
}
private static List<OrderListFieldFilter>? NormalizeOrderFieldFilters(
List<OrderListFieldFilterDto>? filters)
{
if (filters == null || filters.Count == 0)
{
return null;
}
var result = new List<OrderListFieldFilter>();
foreach (var f in filters.Take(MaxOrderFieldFilters))
{
var field = f.Field?.Trim() ?? string.Empty;
var op = string.IsNullOrWhiteSpace(f.Op) ? "like" : f.Op.Trim();
if (!OrderFieldFilterWhitelist.Contains(field) || !OrderFieldFilterOps.Contains(op))
{
continue;
}
op = op.ToLowerInvariant();
if (string.Equals(op, "isNull", StringComparison.OrdinalIgnoreCase))
{
op = "empty";
}
var values = (f.Values ?? [])
.Select(x => TruncateFilterValue(x))
.Where(x => !string.IsNullOrWhiteSpace(x))
.Cast<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MaxOrderInValues)
.ToList();
if (values.Count == 0 && !string.IsNullOrWhiteSpace(f.Value)
&& (op is "in" or "notin"))
{
values = f.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => TruncateFilterValue(x))
.Where(x => !string.IsNullOrWhiteSpace(x))
.Cast<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MaxOrderInValues)
.ToList();
}
var needsValue = op is not ("empty" or "isnotnull");
var truncatedValue = TruncateFilterValue(f.Value);
var truncatedValue2 = TruncateFilterValue(f.Value2);
if (needsValue && values.Count == 0 && string.IsNullOrWhiteSpace(truncatedValue))
{
continue;
}
if (op is "between" && string.IsNullOrWhiteSpace(truncatedValue2) && values.Count < 2)
{
continue;
}
result.Add(new OrderListFieldFilter
{
Field = field,
Op = op,
Value = truncatedValue,
Value2 = truncatedValue2,
Values = values.Count > 0 ? values : null
});
}
return result.Count == 0 ? null : result;
}
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);
}
}