feat(order): add all-orders APIs and query workflow
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m51s
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m51s
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单列表行 DTO。
|
||||
/// </summary>
|
||||
public sealed class OrderAllListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单 ID。
|
||||
/// </summary>
|
||||
public long OrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 下单时间。
|
||||
/// </summary>
|
||||
public DateTime OrderedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 履约方式。
|
||||
/// </summary>
|
||||
public DeliveryType DeliveryType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态。
|
||||
/// </summary>
|
||||
public OrderStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 顾客名称。
|
||||
/// </summary>
|
||||
public string CustomerName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品摘要。
|
||||
/// </summary>
|
||||
public string ItemsSummary { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已退款。
|
||||
/// </summary>
|
||||
public bool IsRefunded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化显示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单统计 DTO。
|
||||
/// </summary>
|
||||
public sealed class OrderAllStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 总订单数。
|
||||
/// </summary>
|
||||
public int TotalOrders { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客单价。
|
||||
/// </summary>
|
||||
public decimal AverageAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款单数。
|
||||
/// </summary>
|
||||
public int RefundCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class OrderAllDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单 ID。
|
||||
/// </summary>
|
||||
public long OrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 履约方式。
|
||||
/// </summary>
|
||||
public DeliveryType DeliveryType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态。
|
||||
/// </summary>
|
||||
public OrderStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod PaymentMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 顾客姓名。
|
||||
/// </summary>
|
||||
public string CustomerName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客手机号。
|
||||
/// </summary>
|
||||
public string CustomerPhone { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客地址。
|
||||
/// </summary>
|
||||
public string CustomerAddress { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品金额。
|
||||
/// </summary>
|
||||
public decimal ItemsAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配送费。
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠金额。
|
||||
/// </summary>
|
||||
public decimal DiscountAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 实付金额。
|
||||
/// </summary>
|
||||
public decimal PaidAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal PayableAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 下单时间。
|
||||
/// </summary>
|
||||
public DateTime OrderedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间。
|
||||
/// </summary>
|
||||
public DateTime? PaidAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 完成时间。
|
||||
/// </summary>
|
||||
public DateTime? FinishedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 取消时间。
|
||||
/// </summary>
|
||||
public DateTime? CancelledAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string Remark { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品明细。
|
||||
/// </summary>
|
||||
public IReadOnlyList<OrderAllDetailItemDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 时间线。
|
||||
/// </summary>
|
||||
public IReadOnlyList<OrderAllTimelineNodeDto> Timeline { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单详情商品行 DTO。
|
||||
/// </summary>
|
||||
public sealed class OrderAllDetailItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品名。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 规格。
|
||||
/// </summary>
|
||||
public string Spec { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价。
|
||||
/// </summary>
|
||||
public decimal UnitPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 小计。
|
||||
/// </summary>
|
||||
public decimal SubTotal { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单时间线节点 DTO。
|
||||
/// </summary>
|
||||
public sealed class OrderAllTimelineNodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 节点名称。
|
||||
/// </summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发生时间。
|
||||
/// </summary>
|
||||
public DateTime OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单导出 DTO。
|
||||
/// </summary>
|
||||
public sealed class OrderAllExportDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件 Base64。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 总记录数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||
using TakeoutSaaS.Domain.Orders.Entities;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单 CSV 导出处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportOrderAllCsvQueryHandler(
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportOrderAllCsvQuery, OrderAllExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<OrderAllExportDto> Handle(ExportOrderAllCsvQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
var orders = (await orderRepository.SearchAllOrdersAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.StartAt,
|
||||
request.EndAt,
|
||||
request.Status,
|
||||
request.DeliveryType,
|
||||
request.PaymentMethod,
|
||||
request.Keyword,
|
||||
cancellationToken))
|
||||
.OrderByDescending(order => order.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
var refundedIds = await LoadRefundedOrderIdsAsync(orders, tenantId, cancellationToken);
|
||||
if (request.RefundedOnly)
|
||||
{
|
||||
orders = orders.Where(order => refundedIds.Contains(order.Id)).ToList();
|
||||
}
|
||||
|
||||
var itemsLookup = await orderRepository.GetItemsByOrderIdsAsync(
|
||||
orders.Select(order => order.Id).ToList(),
|
||||
tenantId,
|
||||
cancellationToken);
|
||||
|
||||
var csv = BuildCsv(orders, refundedIds, itemsLookup);
|
||||
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
|
||||
|
||||
return new OrderAllExportDto
|
||||
{
|
||||
FileName = $"全部订单_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||
FileContentBase64 = Convert.ToBase64String(bytes),
|
||||
TotalCount = orders.Count
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<HashSet<long>> LoadRefundedOrderIdsAsync(
|
||||
IReadOnlyCollection<Order> orders,
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var orderIds = orders.Select(order => order.Id).ToList();
|
||||
if (orderIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var refunded = await orderRepository.GetRefundedOrderIdsAsync(orderIds, tenantId, cancellationToken);
|
||||
return refunded.ToHashSet();
|
||||
}
|
||||
|
||||
private static string BuildCsv(
|
||||
IReadOnlyList<Order> orders,
|
||||
IReadOnlySet<long> refundedIds,
|
||||
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("订单号,下单时间,渠道,顾客,商品,金额,状态");
|
||||
|
||||
foreach (var order in orders)
|
||||
{
|
||||
var isRefunded = refundedIds.Contains(order.Id);
|
||||
var row = new[]
|
||||
{
|
||||
Escape(order.OrderNo),
|
||||
Escape(order.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)),
|
||||
Escape(ToDeliveryTypeText(order.DeliveryType)),
|
||||
Escape(string.IsNullOrWhiteSpace(order.CustomerName) ? "--" : order.CustomerName),
|
||||
Escape(BuildItemSummary(order.Id, itemsLookup)),
|
||||
Escape(ResolveDisplayAmount(order).ToString("0.00", CultureInfo.InvariantCulture)),
|
||||
Escape(ToStatusText(order.Status, isRefunded, order.DeliveryType))
|
||||
};
|
||||
|
||||
sb.AppendLine(string.Join(',', row));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static decimal ResolveDisplayAmount(Order order)
|
||||
{
|
||||
return order.PaidAmount > 0 ? order.PaidAmount : order.PayableAmount;
|
||||
}
|
||||
|
||||
private static string BuildItemSummary(
|
||||
long orderId,
|
||||
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup)
|
||||
{
|
||||
if (!itemsLookup.TryGetValue(orderId, out var items) || items.Count == 0)
|
||||
{
|
||||
return "--";
|
||||
}
|
||||
|
||||
var first = string.IsNullOrWhiteSpace(items[0].ProductName) ? "商品" : items[0].ProductName.Trim();
|
||||
var totalQuantity = items.Sum(item => Math.Max(0, item.Quantity));
|
||||
if (totalQuantity <= 0)
|
||||
{
|
||||
totalQuantity = items.Count;
|
||||
}
|
||||
|
||||
return $"{first}等{totalQuantity}件";
|
||||
}
|
||||
|
||||
private static string ToStatusText(OrderStatus status, bool isRefunded, DeliveryType deliveryType)
|
||||
{
|
||||
if (isRefunded)
|
||||
{
|
||||
return "已退款";
|
||||
}
|
||||
|
||||
return status switch
|
||||
{
|
||||
OrderStatus.PendingPayment => "待接单",
|
||||
OrderStatus.AwaitingPreparation => "待接单",
|
||||
OrderStatus.InProgress => "制作中",
|
||||
OrderStatus.Ready => ToReadyStatusText(deliveryType),
|
||||
OrderStatus.Completed => "已完成",
|
||||
OrderStatus.Cancelled => "已取消",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToReadyStatusText(DeliveryType deliveryType)
|
||||
{
|
||||
return deliveryType switch
|
||||
{
|
||||
DeliveryType.Delivery => "配送中",
|
||||
DeliveryType.Pickup => "待取餐",
|
||||
DeliveryType.DineIn => "待取餐",
|
||||
_ => "待处理"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToDeliveryTypeText(DeliveryType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
DeliveryType.Delivery => "外卖",
|
||||
DeliveryType.Pickup => "自提",
|
||||
DeliveryType.DineIn => "堂食",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string Escape(string input)
|
||||
{
|
||||
if (!input.Contains('"') && !input.Contains(',') && !input.Contains('\n') && !input.Contains('\r'))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
return $"\"{input.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||
using TakeoutSaaS.Domain.Orders.Entities;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetOrderAllDetailQueryHandler(
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetOrderAllDetailQuery, OrderAllDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<OrderAllDetailDto?> Handle(GetOrderAllDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var orderNo = request.OrderNo.Trim();
|
||||
if (string.IsNullOrWhiteSpace(orderNo))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var order = await orderRepository.FindByOrderNoAsync(orderNo, tenantId, cancellationToken);
|
||||
if (order is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (request.StoreId.HasValue && order.StoreId != request.StoreId.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var items = await orderRepository.GetItemsAsync(order.Id, tenantId, cancellationToken);
|
||||
var histories = await orderRepository.GetStatusHistoryAsync(order.Id, tenantId, cancellationToken);
|
||||
var payment = await orderRepository.GetLatestPaymentRecordAsync(order.Id, tenantId, cancellationToken);
|
||||
var delivery = await orderRepository.GetLatestDeliveryOrderAsync(order.Id, tenantId, cancellationToken);
|
||||
|
||||
return new OrderAllDetailDto
|
||||
{
|
||||
OrderId = order.Id,
|
||||
OrderNo = order.OrderNo,
|
||||
StoreId = order.StoreId,
|
||||
DeliveryType = order.DeliveryType,
|
||||
Status = order.Status,
|
||||
PaymentMethod = payment?.Method ?? PaymentMethod.Unknown,
|
||||
CustomerName = string.IsNullOrWhiteSpace(order.CustomerName) ? "--" : order.CustomerName,
|
||||
CustomerPhone = string.IsNullOrWhiteSpace(order.CustomerPhone) ? "--" : order.CustomerPhone,
|
||||
CustomerAddress = "--",
|
||||
ItemsAmount = order.ItemsAmount,
|
||||
DeliveryFee = delivery?.DeliveryFee ?? 0,
|
||||
DiscountAmount = order.DiscountAmount,
|
||||
PayableAmount = order.PayableAmount,
|
||||
PaidAmount = ResolveDisplayAmount(order),
|
||||
OrderedAt = order.CreatedAt,
|
||||
PaidAt = order.PaidAt,
|
||||
FinishedAt = order.FinishedAt,
|
||||
CancelledAt = order.CancelledAt,
|
||||
Remark = order.Remark?.Trim() ?? string.Empty,
|
||||
Items = items.Select(item => new OrderAllDetailItemDto
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(item.ProductName) ? "--" : item.ProductName,
|
||||
Spec = string.IsNullOrWhiteSpace(item.SkuName) ? "标准" : item.SkuName,
|
||||
Quantity = item.Quantity,
|
||||
UnitPrice = item.UnitPrice,
|
||||
SubTotal = item.SubTotal
|
||||
}).ToList(),
|
||||
Timeline = BuildTimeline(order, histories)
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal ResolveDisplayAmount(Order order)
|
||||
{
|
||||
return order.PaidAmount > 0 ? order.PaidAmount : order.PayableAmount;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OrderAllTimelineNodeDto> BuildTimeline(
|
||||
Order order,
|
||||
IReadOnlyList<OrderStatusHistory> histories)
|
||||
{
|
||||
var nodes = histories
|
||||
.OrderBy(history => history.OccurredAt)
|
||||
.Select(history => new OrderAllTimelineNodeDto
|
||||
{
|
||||
Label = ToTimelineLabel(history.Status, order.DeliveryType),
|
||||
OccurredAt = history.OccurredAt
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (nodes.Count > 0)
|
||||
{
|
||||
return nodes;
|
||||
}
|
||||
|
||||
var fallback = new List<OrderAllTimelineNodeDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Label = "下单",
|
||||
OccurredAt = order.CreatedAt
|
||||
}
|
||||
};
|
||||
|
||||
if (order.PaidAt.HasValue)
|
||||
{
|
||||
fallback.Add(new OrderAllTimelineNodeDto
|
||||
{
|
||||
Label = "支付成功",
|
||||
OccurredAt = order.PaidAt.Value
|
||||
});
|
||||
}
|
||||
|
||||
if (order.FinishedAt.HasValue)
|
||||
{
|
||||
fallback.Add(new OrderAllTimelineNodeDto
|
||||
{
|
||||
Label = "已完成",
|
||||
OccurredAt = order.FinishedAt.Value
|
||||
});
|
||||
}
|
||||
|
||||
if (order.CancelledAt.HasValue)
|
||||
{
|
||||
fallback.Add(new OrderAllTimelineNodeDto
|
||||
{
|
||||
Label = "已取消",
|
||||
OccurredAt = order.CancelledAt.Value
|
||||
});
|
||||
}
|
||||
|
||||
return fallback
|
||||
.OrderBy(item => item.OccurredAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string ToTimelineLabel(OrderStatus status, DeliveryType deliveryType)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
OrderStatus.PendingPayment => "下单",
|
||||
OrderStatus.AwaitingPreparation => "商家接单",
|
||||
OrderStatus.InProgress => "制作中",
|
||||
OrderStatus.Ready => ToReadyStatusText(deliveryType),
|
||||
OrderStatus.Completed => "已完成",
|
||||
OrderStatus.Cancelled => "已取消",
|
||||
_ => "状态变更"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToReadyStatusText(DeliveryType deliveryType)
|
||||
{
|
||||
return deliveryType switch
|
||||
{
|
||||
DeliveryType.Delivery => "配送中",
|
||||
DeliveryType.Pickup => "待取餐",
|
||||
DeliveryType.DineIn => "待取餐",
|
||||
_ => "待处理"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||
using TakeoutSaaS.Domain.Orders.Entities;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单统计查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetOrderAllStatsQueryHandler(
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetOrderAllStatsQuery, OrderAllStatsDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<OrderAllStatsDto> Handle(GetOrderAllStatsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
var orders = (await orderRepository.SearchAllOrdersAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.StartAt,
|
||||
request.EndAt,
|
||||
request.Status,
|
||||
request.DeliveryType,
|
||||
request.PaymentMethod,
|
||||
request.Keyword,
|
||||
cancellationToken))
|
||||
.ToList();
|
||||
|
||||
var refundedIds = await LoadRefundedOrderIdsAsync(orders, tenantId, cancellationToken);
|
||||
if (request.RefundedOnly)
|
||||
{
|
||||
orders = orders.Where(order => refundedIds.Contains(order.Id)).ToList();
|
||||
}
|
||||
|
||||
var totalOrders = orders.Count;
|
||||
if (totalOrders == 0)
|
||||
{
|
||||
return new OrderAllStatsDto();
|
||||
}
|
||||
|
||||
var totalAmount = orders.Sum(ResolveDisplayAmount);
|
||||
var averageAmount = decimal.Round(totalAmount / totalOrders, 2, MidpointRounding.AwayFromZero);
|
||||
var refundCount = request.RefundedOnly
|
||||
? totalOrders
|
||||
: orders.Count(order => refundedIds.Contains(order.Id));
|
||||
|
||||
return new OrderAllStatsDto
|
||||
{
|
||||
TotalOrders = totalOrders,
|
||||
TotalAmount = totalAmount,
|
||||
AverageAmount = averageAmount,
|
||||
RefundCount = refundCount
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<HashSet<long>> LoadRefundedOrderIdsAsync(
|
||||
IReadOnlyCollection<Order> orders,
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var orderIds = orders.Select(order => order.Id).ToList();
|
||||
if (orderIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var refunded = await orderRepository.GetRefundedOrderIdsAsync(orderIds, tenantId, cancellationToken);
|
||||
return refunded.ToHashSet();
|
||||
}
|
||||
|
||||
private static decimal ResolveDisplayAmount(Order order)
|
||||
{
|
||||
return order.PaidAmount > 0 ? order.PaidAmount : order.PayableAmount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||
using TakeoutSaaS.Domain.Orders.Entities;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchOrderAllListQueryHandler(
|
||||
IOrderRepository orderRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SearchOrderAllListQuery, PagedResult<OrderAllListItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<OrderAllListItemDto>> Handle(SearchOrderAllListQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
var orders = await orderRepository.SearchAllOrdersAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.StartAt,
|
||||
request.EndAt,
|
||||
request.Status,
|
||||
request.DeliveryType,
|
||||
request.PaymentMethod,
|
||||
request.Keyword,
|
||||
cancellationToken);
|
||||
|
||||
var filteredOrders = orders.ToList();
|
||||
var refundedSet = await LoadRefundedOrderIdsAsync(filteredOrders, tenantId, cancellationToken);
|
||||
if (request.RefundedOnly)
|
||||
{
|
||||
filteredOrders = filteredOrders
|
||||
.Where(order => refundedSet.Contains(order.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var sorted = ApplySorting(filteredOrders, request.SortBy, request.SortDescending);
|
||||
var page = Math.Max(1, request.Page);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
var pagedOrders = sorted
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
var itemsLookup = await orderRepository.GetItemsByOrderIdsAsync(
|
||||
pagedOrders.Select(order => order.Id).ToList(),
|
||||
tenantId,
|
||||
cancellationToken);
|
||||
|
||||
var rows = pagedOrders
|
||||
.Select(order =>
|
||||
{
|
||||
var isRefunded = refundedSet.Contains(order.Id);
|
||||
return new OrderAllListItemDto
|
||||
{
|
||||
OrderId = order.Id,
|
||||
OrderNo = order.OrderNo,
|
||||
OrderedAt = order.CreatedAt,
|
||||
DeliveryType = order.DeliveryType,
|
||||
Status = order.Status,
|
||||
CustomerName = string.IsNullOrWhiteSpace(order.CustomerName) ? "--" : order.CustomerName,
|
||||
ItemsSummary = BuildItemSummary(order.Id, itemsLookup),
|
||||
Amount = ResolveDisplayAmount(order),
|
||||
IsRefunded = isRefunded,
|
||||
IsDimmed = order.Status == OrderStatus.Cancelled || isRefunded
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PagedResult<OrderAllListItemDto>(rows, page, pageSize, filteredOrders.Count);
|
||||
}
|
||||
|
||||
private async Task<HashSet<long>> LoadRefundedOrderIdsAsync(
|
||||
IReadOnlyCollection<Order> orders,
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var orderIds = orders.Select(order => order.Id).ToList();
|
||||
if (orderIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var refunded = await orderRepository.GetRefundedOrderIdsAsync(orderIds, tenantId, cancellationToken);
|
||||
return refunded.ToHashSet();
|
||||
}
|
||||
|
||||
private static decimal ResolveDisplayAmount(Order order)
|
||||
{
|
||||
return order.PaidAmount > 0 ? order.PaidAmount : order.PayableAmount;
|
||||
}
|
||||
|
||||
private static string BuildItemSummary(
|
||||
long orderId,
|
||||
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup)
|
||||
{
|
||||
if (!itemsLookup.TryGetValue(orderId, out var items) || items.Count == 0)
|
||||
{
|
||||
return "--";
|
||||
}
|
||||
|
||||
var first = string.IsNullOrWhiteSpace(items[0].ProductName) ? "商品" : items[0].ProductName.Trim();
|
||||
var totalQuantity = items.Sum(item => Math.Max(0, item.Quantity));
|
||||
if (totalQuantity <= 0)
|
||||
{
|
||||
totalQuantity = items.Count;
|
||||
}
|
||||
|
||||
return $"{first}等{totalQuantity}件";
|
||||
}
|
||||
|
||||
private static IEnumerable<Order> ApplySorting(
|
||||
IReadOnlyCollection<Order> orders,
|
||||
string? sortBy,
|
||||
bool sortDescending)
|
||||
{
|
||||
return sortBy?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"amount" => sortDescending
|
||||
? orders.OrderByDescending(ResolveDisplayAmount).ThenByDescending(order => order.CreatedAt)
|
||||
: orders.OrderBy(ResolveDisplayAmount).ThenBy(order => order.CreatedAt),
|
||||
"status" => sortDescending
|
||||
? orders.OrderByDescending(order => order.Status).ThenByDescending(order => order.CreatedAt)
|
||||
: orders.OrderBy(order => order.Status).ThenBy(order => order.CreatedAt),
|
||||
_ => sortDescending
|
||||
? orders.OrderByDescending(order => order.CreatedAt)
|
||||
: orders.OrderBy(order => order.CreatedAt)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单 CSV 导出查询。
|
||||
/// </summary>
|
||||
public sealed class ExportOrderAllCsvQuery : IRequest<OrderAllExportDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(含)。
|
||||
/// </summary>
|
||||
public DateTime? StartAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(不含)。
|
||||
/// </summary>
|
||||
public DateTime? EndAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public OrderStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否仅退款。
|
||||
/// </summary>
|
||||
public bool RefundedOnly { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 履约方式。
|
||||
/// </summary>
|
||||
public DeliveryType? DeliveryType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod? PaymentMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单详情查询。
|
||||
/// </summary>
|
||||
public sealed class GetOrderAllDetailQuery : IRequest<OrderAllDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单统计查询。
|
||||
/// </summary>
|
||||
public sealed class GetOrderAllStatsQuery : IRequest<OrderAllStatsDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(含)。
|
||||
/// </summary>
|
||||
public DateTime? StartAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(不含)。
|
||||
/// </summary>
|
||||
public DateTime? EndAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public OrderStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否仅退款。
|
||||
/// </summary>
|
||||
public bool RefundedOnly { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 履约方式。
|
||||
/// </summary>
|
||||
public DeliveryType? DeliveryType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod? PaymentMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单列表查询。
|
||||
/// </summary>
|
||||
public sealed class SearchOrderAllListQuery : IRequest<PagedResult<OrderAllListItemDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(含)。
|
||||
/// </summary>
|
||||
public DateTime? StartAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(不含)。
|
||||
/// </summary>
|
||||
public DateTime? EndAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public OrderStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否仅退款。
|
||||
/// </summary>
|
||||
public bool RefundedOnly { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 履约方式。
|
||||
/// </summary>
|
||||
public DeliveryType? DeliveryType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod? PaymentMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词(订单号/手机号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段。
|
||||
/// </summary>
|
||||
public string? SortBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否倒序。
|
||||
/// </summary>
|
||||
public bool SortDescending { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单导出查询验证器。
|
||||
/// </summary>
|
||||
public sealed class ExportOrderAllCsvQueryValidator : AbstractValidator<ExportOrderAllCsvQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public ExportOrderAllCsvQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.Keyword).MaximumLength(64);
|
||||
RuleFor(x => x)
|
||||
.Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
|
||||
.WithMessage("开始时间必须早于结束时间");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单详情查询验证器。
|
||||
/// </summary>
|
||||
public sealed class GetOrderAllDetailQueryValidator : AbstractValidator<GetOrderAllDetailQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public GetOrderAllDetailQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.OrderNo).NotEmpty().MaximumLength(32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单统计查询验证器。
|
||||
/// </summary>
|
||||
public sealed class GetOrderAllStatsQueryValidator : AbstractValidator<GetOrderAllStatsQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public GetOrderAllStatsQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.Keyword).MaximumLength(64);
|
||||
RuleFor(x => x)
|
||||
.Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
|
||||
.WithMessage("开始时间必须早于结束时间");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Orders.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 全部订单列表查询验证器。
|
||||
/// </summary>
|
||||
public sealed class SearchOrderAllListQueryValidator : AbstractValidator<SearchOrderAllListQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public SearchOrderAllListQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.Page).GreaterThan(0);
|
||||
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
||||
RuleFor(x => x.Keyword).MaximumLength(64);
|
||||
RuleFor(x => x.SortBy).MaximumLength(64);
|
||||
RuleFor(x => x)
|
||||
.Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
|
||||
.WithMessage("开始时间必须早于结束时间");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user