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;
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;
///
/// 订单相关 API 服务。
///
[ApiExplorerSettings(GroupName = "OMS-Order")]
public partial class OrderService : ApplicationService
{
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService();
private IOrderTemplateRepository OrderTemplateRepository => LazyServiceProvider.GetRequiredService();
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService();
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService();
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService();
private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService();
private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService();
private LabelPdfGenerationService LabelPdfGenerationService =>
LazyServiceProvider.GetRequiredService();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
LazyServiceProvider.GetRequiredService();
private EffectiveScopeBuilder DataScopeBuilder =>
LazyServiceProvider.GetRequiredService();
///
/// 查询订单列表。
///
/// 入参:订单分页查询条件。
/// 订单分页结果,包含列表数据和总记录数。
[HttpPost("/order/list")]
public async Task> 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 { Items = await MapOrderOutputsAsync(orders), Total = total };
}
///
/// 按状态聚合统计订单数量(单次查询,供 Tab 角标展示)。
///
[HttpGet("/order/status-counts")]
public async Task 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;
}
///
/// 查询订单详情。
///
/// 入参:目标主键。
/// 订单详情。
[HttpGet("/order/detail")]
public async Task GetDetailAsync([FromQuery] int id)
{
var order = await GetOrderOrThrowAsync(id);
return (await MapOrderOutputsAsync([order])).First();
}
///
/// 执行订单支付(余额扣款,支持支付时选券)。
///
/// 入参:订单 Id;couponInstanceId 为 null 沿用已绑定券,0 不用券,大于 0 使用指定券。
/// 无返回体;指定券不可用、余额不足或订单状态不可支付时抛业务异常。
[HttpPost("/order/pay")]
public async Task PayAsync(OrderPayInput 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 breakdown = await BuildPayBreakdownAsync(order, fees, input.CouponInstanceId);
if (breakdown.ReceivableSubtotal <= 0)
{
throw new BusinessException("订单应付金额无效,无法支付");
}
var payAmount = breakdown.PayableAmount;
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
if (payAmount > 0)
{
var balanceMutator = LazyServiceProvider.GetRequiredService();
await balanceMutator.DebitOrderPayAsync(
customer,
payAmount,
order.Id,
order.ServiceNo,
$"订单支付 {order.ServiceNo}");
}
var couponManager = LazyServiceProvider.GetRequiredService();
if (order.CouponInstanceId is > 0 && order.CouponInstanceId != breakdown.CouponInstanceId)
{
await couponManager.ReleaseLockAsync(order.CouponInstanceId.Value, order.Id);
}
LazyServiceProvider.GetRequiredService()
.ApplySnapshotToOrder(order, breakdown, null);
string? couponCode = null;
if (breakdown.CouponInstanceId.HasValue)
{
var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id);
var coupon = await couponManager.MarkUsedAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id);
couponCode = coupon.CouponCode;
}
await UpsertMarketingDiscountFeesAsync(order, fees, breakdown, couponCode);
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer);
var pointsManager = LazyServiceProvider.GetRequiredService();
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);
var couponText = breakdown.CouponDiscountAmount > 0
? $",优惠券抵扣 {breakdown.CouponDiscountAmount.ToString("0.####", CultureInfo.InvariantCulture)}"
: string.Empty;
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.Pay,
beforeSnap,
afterSnap,
$"支付金额 {amountText}{couponText}");
}
///
/// 标记订单派送成功(触发引荐分成 Pending)。
///
[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();
await commissionManager.OnOrderDeliverySuccessAsync(order.Id);
var couponManager = LazyServiceProvider.GetRequiredService();
await couponManager.OnFirstDeliverySuccessAsync(order);
}
///
/// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。
///
[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();
await couponManager.RestoreOnOrderRefundAsync(order, input.IsFullRefund);
order.IsRefunded = 1;
await UpdateOrderAsync(order);
var commissionManager = LazyServiceProvider.GetRequiredService();
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();
await pointsManager.ClawbackOnOrderAsync(
mainCustomer.Id,
order.Id,
order.ServiceNo,
"订单退款扣回积分");
}
}
}
///
/// 执行订单预报。
///
/// 入参:订单操作参数。
/// 无返回体,仅完成订单预报处理。
[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);
}
///
/// 执行订单拦截。
///
/// 入参:订单操作参数。
/// 无返回体,仅完成订单拦截处理。
[HttpPost("/order/intercept")]
public async Task InterceptAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
order.IsIntercept = 1;
await UpdateOrderAsync(order);
}
///
/// 取消订单拦截。
///
/// 入参:订单操作参数。
/// 无返回体,仅完成取消订单拦截。
[HttpPost("/order/cancel-intercept")]
public async Task CancelInterceptAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
order.IsIntercept = 0;
await UpdateOrderAsync(order);
}
///
/// 删除订单。
///
/// 入参:订单操作参数。
/// 无返回体,仅完成删除订单。
[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();
await pointsManager.ClawbackOnOrderAsync(
mainCustomer.Id,
order.Id,
order.ServiceNo,
"订单删除扣回积分");
}
}
await OrderRepository.UpdateAsync(order);
await OrderRepository.DeleteAsync(order);
}
///
/// 打印订单。
///
/// 入参:订单操作参数。
/// 订单打印结果。
[HttpPost("/order/print")]
public async Task 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() : 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
};
}
///
/// 批量获取打印面单地址(同步返回,避免前端逐单请求)。
///
/// 入参:订单 ID 列表。
/// 各订单打印结果。
[HttpPost("/order/print/batch")]
public async Task 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(orderIds.Count)
};
// 已有系统面单一律批量查出,避免 N 次附件查询
var existingUrls = await OrderFileManager.GetSystemLabelUrlsAsync(orderIds);
var pendingIds = new List();
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;
}
///
/// 导出订单。
///
/// 入参:订单导出筛选条件。
/// 订单导出结果。
[HttpPost("/order/export")]
public async Task 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)
};
}
///
/// 查价运输方式选项(按销售产品分组)。
///
[HttpGet("/order/price-check/shipping-options")]
public Task GetPriceCheckShippingOptionsAsync() =>
BuildPriceCheckShippingOptionsAsync();
///
/// 执行订单运费试算。
///
/// 入参:订单运费试算参数。
/// 订单运费试算列表结果。
[HttpPost("/order/price-check")]
public async Task PriceCheckAsync(OrderPriceCheckInput input)
{
if (!input.CustomerId.HasValue || input.CustomerId.Value <= 0)
{
input.CustomerId = await ResolvePriceCheckCustomerIdAsync();
}
return await BuildPriceCheckResultAsync(input);
}
///
/// 查询订单模板列表。
///
/// 入参:订单模板列表查询条件。
/// 订单模板分页结果,包含列表数据和总记录数。
[HttpGet("/order/template/list")]
public async Task> GetTemplateListAsync([FromQuery] OrderTemplateListInput input)
{
var items = await GetTemplateOutputsAsync(input);
return items;
}
///
/// 下载订单模板文件。
///
/// 入参:模板主键。
/// 文件流响应。
[HttpGet("/order/template/download")]
public async Task DownloadTemplateAsync([FromQuery] int templateId)
{
return await BuildTemplateDownloadAsync(templateId);
}
///
/// 导入订单文件,创建 import-row 批量任务并返回 jobId。
///
/// 入参:订单导入参数。
/// 批量任务 ID 及是否异步处理。
[HttpPost("/order/upload")]
public async Task UploadAsync([FromForm] OrderUploadInput input)
{
return await ImportOrdersAsync(input);
}
///
/// 创建订单包裹(PC/H5 确认下单或保存草稿)。整段在一个事务里,异常直接抛出并回滚。
///
/// 【本请求内】RequestId 幂等 → 校验 → 黑名单 → 下单前拦截规则 → 落库订单与子表。
/// 保存草稿只再写 9610 与上门揽收;确认下单额外标记「预报处理中」,并写入预报计费、面单、自动触发。
///
///
/// 【同一事务的 Outbox,Commit 后才投递】9610、预报计费、面单、OrderCreate 自动触发、上门揽收。
/// 队列关闭时预报计费在本事务内同步执行。轨迹不在这里发,等预报消费端排货成功后再发。
///
///
/// 入参:订单包裹创建参数。SaveAsDraft=true 为仅保存草稿。
///
/// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已入队;
/// 上门揽收入队成功时 PickupQueued=true,失败只写 PickupCreateError,不回滚订单。
///
[HttpPost("/order/parcel/create")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public async Task CreateParcelAsync(OrderParcelCreateInput input)
{
return await CreateParcelCoreAsync(input, createContext: BuildParcelCreateContext(input));
}
///
/// 客户 OpenAPI 创单:同一客户相同 clientNo 视为幂等,返回已存在订单,不新建。
///
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public async Task 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;
}
try
{
var uowManager = LazyServiceProvider.GetRequiredService();
if (OmsOutboxUnitOfWork.IsAmbientTransactional(uowManager))
{
await EventBus.PublishAsync(new OrderParcelPickupAttachIntegrationEvent
{
OrderId = output.OrderId,
CustomerId = input.CustomerId,
SenderAddressId = input.SenderAddressId.Value,
WarehouseId = input.WarehouseId.Value,
PickupDirection = input.PickupDirection,
CarrierCode = input.CarrierCode,
CarrierService = input.CarrierService,
ExpectPickupTime = input.ExpectPickupTime.Value
});
output.PickupQueued = true;
return;
}
// 无事务 UoW(店铺订单创建运单)时同步挂接揽收,避免 Outbox 拒发
var pickupService = LazyServiceProvider.GetRequiredService();
await pickupService.CreateForParcelInternalAsync(new PickupOrderCreateInput
{
CustomerId = input.CustomerId,
SenderAddressId = input.SenderAddressId.Value,
Direction = PickupDirectionCodes.NormalizeOrDefault(input.PickupDirection),
WarehouseId = input.WarehouseId.Value,
CarrierCode = input.CarrierCode,
CarrierService = input.CarrierService,
ExpectPickupTime = input.ExpectPickupTime.Value,
OrderIds = [output.OrderId],
Remark = null
});
output.PickupQueued = true;
}
catch (Exception ex)
{
output.PickupCreateError = ex.Message;
}
}
///
/// 批量创建订单包裹。
///
/// 入参:批量创建小包请求集合,每条记录沿用单票创建结构。
/// 批量处理结果,包含批次号、成功失败数量与逐条执行明细。
[HttpPost("/order/parcel/batch-create")]
[UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)]
public async Task BatchCreateParcelAsync(OrderParcelBatchCreateInput input)
{
if (input == null || input.Count == 0)
{
throw new BusinessException("批量创建请求不能为空");
}
if (input.Count > ParcelBatchMaxCount)
{
throw new BusinessException($"单次批量创建不能超过{ParcelBatchMaxCount}条");
}
if (BatchQueueEnabled)
{
return await BatchCreateParcelViaQueueAsync(input);
}
return await BatchCreateParcelSyncAsync(input);
}
private async Task BatchCreateParcelViaQueueAsync(OrderParcelBatchCreateInput input)
{
var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}";
var operatorUserId = CurrentUser.GetId();
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 BatchCreateParcelSyncAsync(OrderParcelBatchCreateInput input)
{
var batchNo = $"PCB{DateTime.Now:yyyyMMddHHmmssfff}";
var items = new List(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;
}
/// 订单列表默认时间窗(天)。无精确单号且未传时间时兜底,防全表扫。
private const int OrderListDefaultDays = 90;
///
/// 无创建时间且非「精确单号查询」时,自动限制近 N 天。
///
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(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 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 OrderFieldFilterOps = new(StringComparer.OrdinalIgnoreCase)
{
"like", "eq", "ne", "in", "notIn", "gt", "gte", "lt", "lte", "between",
"empty", "isNull", "isNotNull"
};
private static readonly HashSet OrderSortFieldWhitelist = new(StringComparer.OrdinalIgnoreCase)
{
"createTime",
"updateTime",
"serviceNo",
"clientNo",
"trackNo",
"primaryBinCode",
"payableAmount",
"payTime",
"shipTime",
"receiveTime",
"signTime"
};
/// 单次列表最多接受的 filters 条数。
private const int MaxOrderFieldFilters = 20;
/// IN / NOT IN 单字段最多值数量(防几万单号打爆 SQL/参数包)。
private const int MaxOrderInValues = 500;
/// 单值最大长度(单号/文本)。
private const int MaxOrderFilterValueLength = 128;
/// keyword 最大长度。
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? NormalizeOrderFieldFilters(
List? filters)
{
if (filters == null || filters.Count == 0)
{
return null;
}
var result = new List();
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()
.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()
.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 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);
}
}