From 11a1521b6a8aca26db51dc3017f05593e6c33e99 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Fri, 27 Feb 2026 10:09:19 +0800 Subject: [PATCH] feat(order): add all-orders APIs and query workflow --- .../Contracts/Order/OrderContracts.cs | 331 ++++++++++++++++ .../Controllers/OrderController.cs | 373 ++++++++++++++++++ .../App/Orders/Dto/OrderAllDtos.cs | 265 +++++++++++++ .../Handlers/ExportOrderAllCsvQueryHandler.cs | 178 +++++++++ .../Handlers/GetOrderAllDetailQueryHandler.cs | 167 ++++++++ .../Handlers/GetOrderAllStatsQueryHandler.cs | 81 ++++ .../SearchOrderAllListQueryHandler.cs | 138 +++++++ .../Orders/Queries/ExportOrderAllCsvQuery.cs | 52 +++ .../Orders/Queries/GetOrderAllDetailQuery.cs | 20 + .../Orders/Queries/GetOrderAllStatsQuery.cs | 52 +++ .../Orders/Queries/SearchOrderAllListQuery.cs | 73 ++++ .../ExportOrderAllCsvQueryValidator.cs | 21 + .../GetOrderAllDetailQueryValidator.cs | 18 + .../GetOrderAllStatsQueryValidator.cs | 21 + .../SearchOrderAllListQueryValidator.cs | 24 ++ .../Orders/Repositories/IOrderRepository.cs | 68 ++++ .../App/Repositories/EfOrderRepository.cs | 138 +++++++ 17 files changed, 2020 insertions(+) create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Order/OrderContracts.cs create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/OrderController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderAllDtos.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Handlers/ExportOrderAllCsvQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderAllDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderAllStatsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrderAllListQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Queries/ExportOrderAllCsvQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Queries/GetOrderAllDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Queries/GetOrderAllStatsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Queries/SearchOrderAllListQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Validators/ExportOrderAllCsvQueryValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Validators/GetOrderAllDetailQueryValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Validators/GetOrderAllStatsQueryValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Orders/Validators/SearchOrderAllListQueryValidator.cs diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Order/OrderContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Order/OrderContracts.cs new file mode 100644 index 0000000..a6503c2 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Order/OrderContracts.cs @@ -0,0 +1,331 @@ +namespace TakeoutSaaS.TenantApi.Contracts.Order; + +/// +/// 全部订单筛选请求。 +/// +public class OrderAllFilterRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 开始日期(yyyy-MM-dd)。 + /// + public string? StartDate { get; set; } + + /// + /// 结束日期(yyyy-MM-dd)。 + /// + public string? EndDate { get; set; } + + /// + /// 状态筛选。 + /// + public string? Status { get; set; } + + /// + /// 渠道筛选(delivery/pickup/dine_in)。 + /// + public string? Channel { get; set; } + + /// + /// 支付方式筛选(wechat/alipay/balance/cash/card)。 + /// + public string? PaymentMethod { get; set; } + + /// + /// 关键词(订单号/手机号)。 + /// + public string? Keyword { get; set; } +} + +/// +/// 全部订单列表请求。 +/// +public sealed class OrderAllListRequest : OrderAllFilterRequest +{ + /// + /// 页码。 + /// + public int Page { get; set; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } = 20; +} + +/// +/// 全部订单详情请求。 +/// +public sealed class OrderAllDetailRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 订单号。 + /// + public string OrderNo { get; set; } = string.Empty; +} + +/// +/// 全部订单列表结果。 +/// +public sealed class OrderAllListResultResponse +{ + /// + /// 列表。 + /// + public List Items { get; set; } = []; + + /// + /// 总数。 + /// + public int Total { get; set; } + + /// + /// 页码。 + /// + public int Page { get; set; } + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } +} + +/// +/// 全部订单行。 +/// +public sealed class OrderAllListItemResponse +{ + /// + /// 订单号。 + /// + public string OrderNo { get; set; } = string.Empty; + + /// + /// 下单时间。 + /// + public string OrderedAt { get; set; } = string.Empty; + + /// + /// 渠道。 + /// + public string Channel { get; set; } = string.Empty; + + /// + /// 顾客。 + /// + public string Customer { get; set; } = string.Empty; + + /// + /// 商品摘要。 + /// + public string ItemsSummary { get; set; } = string.Empty; + + /// + /// 金额。 + /// + public decimal Amount { get; set; } + + /// + /// 状态。 + /// + public string Status { get; set; } = string.Empty; + + /// + /// 是否弱化展示。 + /// + public bool IsDimmed { get; set; } +} + +/// +/// 全部订单统计。 +/// +public sealed class OrderAllStatsResponse +{ + /// + /// 订单数。 + /// + public int TotalOrders { get; set; } + + /// + /// 总金额。 + /// + public decimal TotalAmount { get; set; } + + /// + /// 平均客单价。 + /// + public decimal AverageAmount { get; set; } + + /// + /// 退款单数。 + /// + public int RefundCount { get; set; } +} + +/// +/// 全部订单详情。 +/// +public sealed class OrderAllDetailResponse +{ + /// + /// 订单号。 + /// + public string OrderNo { get; set; } = string.Empty; + + /// + /// 渠道。 + /// + public string Channel { get; set; } = string.Empty; + + /// + /// 状态。 + /// + public string Status { get; set; } = string.Empty; + + /// + /// 支付方式。 + /// + public string PaymentMethod { get; set; } = string.Empty; + + /// + /// 下单时间。 + /// + public string OrderedAt { get; set; } = string.Empty; + + /// + /// 支付时间。 + /// + public string? PaidAt { get; set; } + + /// + /// 完成时间。 + /// + public string? FinishedAt { get; set; } + + /// + /// 顾客姓名。 + /// + public string CustomerName { get; set; } = string.Empty; + + /// + /// 顾客手机号。 + /// + public string CustomerPhone { get; set; } = string.Empty; + + /// + /// 收货地址。 + /// + public string CustomerAddress { get; set; } = string.Empty; + + /// + /// 商品金额。 + /// + public decimal ItemsAmount { get; set; } + + /// + /// 配送费。 + /// + public decimal DeliveryFee { get; set; } + + /// + /// 优惠减免。 + /// + public decimal DiscountAmount { get; set; } + + /// + /// 实付金额。 + /// + public decimal PaidAmount { get; set; } + + /// + /// 备注。 + /// + public string Remark { get; set; } = string.Empty; + + /// + /// 商品明细。 + /// + public List Items { get; set; } = []; + + /// + /// 状态时间线。 + /// + public List Timeline { get; set; } = []; +} + +/// +/// 全部订单商品明细行。 +/// +public sealed class OrderAllDetailItemResponse +{ + /// + /// 商品名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 规格。 + /// + public string Spec { get; set; } = string.Empty; + + /// + /// 数量。 + /// + public int Quantity { get; set; } + + /// + /// 单价。 + /// + public decimal UnitPrice { get; set; } + + /// + /// 小计。 + /// + public decimal SubTotal { get; set; } +} + +/// +/// 全部订单时间线节点。 +/// +public sealed class OrderAllTimelineResponse +{ + /// + /// 节点文案。 + /// + public string Label { get; set; } = string.Empty; + + /// + /// 时间。 + /// + public string Time { get; set; } = string.Empty; +} + +/// +/// 全部订单导出回执。 +/// +public sealed class OrderAllExportResponse +{ + /// + /// 文件名。 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// 文件 Base64。 + /// + public string FileContentBase64 { get; set; } = string.Empty; + + /// + /// 导出记录数。 + /// + public int TotalCount { get; set; } +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/OrderController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/OrderController.cs new file mode 100644 index 0000000..92abcdc --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/OrderController.cs @@ -0,0 +1,373 @@ +using System.Globalization; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Orders.Dto; +using TakeoutSaaS.Application.App.Orders.Queries; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Infrastructure.App.Persistence; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; +using TakeoutSaaS.TenantApi.Contracts.Order; + +namespace TakeoutSaaS.TenantApi.Controllers; + +/// +/// 租户端订单管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/tenant/v{version:apiVersion}/order")] +public sealed class OrderController( + IMediator mediator, + TakeoutAppDbContext dbContext, + StoreContextService storeContextService) : BaseApiController +{ + /// + /// 全部订单列表。 + /// + [HttpGet("all/list")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> List( + [FromQuery] OrderAllListRequest request, + CancellationToken cancellationToken) + { + var (storeId, startAt, endAt, status, refundedOnly, deliveryType, paymentMethod) = + await ParseFilterAsync(request, cancellationToken); + + var result = await mediator.Send(new SearchOrderAllListQuery + { + StoreId = storeId, + StartAt = startAt, + EndAt = endAt, + Status = status, + RefundedOnly = refundedOnly, + DeliveryType = deliveryType, + PaymentMethod = paymentMethod, + Keyword = request.Keyword, + Page = Math.Max(1, request.Page), + PageSize = Math.Clamp(request.PageSize, 1, 200), + SortBy = "createdAt", + SortDescending = true + }, cancellationToken); + + return ApiResponse.Ok(new OrderAllListResultResponse + { + Items = result.Items.Select(MapListItem).ToList(), + Total = result.TotalCount, + Page = result.Page, + PageSize = result.PageSize + }); + } + + /// + /// 全部订单统计。 + /// + [HttpGet("all/stats")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Stats( + [FromQuery] OrderAllFilterRequest request, + CancellationToken cancellationToken) + { + var (storeId, startAt, endAt, status, refundedOnly, deliveryType, paymentMethod) = + await ParseFilterAsync(request, cancellationToken); + + var result = await mediator.Send(new GetOrderAllStatsQuery + { + StoreId = storeId, + StartAt = startAt, + EndAt = endAt, + Status = status, + RefundedOnly = refundedOnly, + DeliveryType = deliveryType, + PaymentMethod = paymentMethod, + Keyword = request.Keyword + }, cancellationToken); + + return ApiResponse.Ok(new OrderAllStatsResponse + { + TotalOrders = result.TotalOrders, + TotalAmount = result.TotalAmount, + AverageAmount = result.AverageAmount, + RefundCount = result.RefundCount + }); + } + + /// + /// 全部订单详情。 + /// + [HttpGet("all/detail")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Detail( + [FromQuery] OrderAllDetailRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var orderNo = request.OrderNo?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(orderNo)) + { + return ApiResponse.Error(ErrorCodes.BadRequest, "orderNo 非法"); + } + + var result = await mediator.Send(new GetOrderAllDetailQuery + { + StoreId = storeId, + OrderNo = orderNo + }, cancellationToken); + + if (result is null) + { + return ApiResponse.Error(ErrorCodes.NotFound, "订单不存在"); + } + + return ApiResponse.Ok(MapDetail(result)); + } + + /// + /// 全部订单导出。 + /// + [HttpGet("all/export")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Export( + [FromQuery] OrderAllFilterRequest request, + CancellationToken cancellationToken) + { + var (storeId, startAt, endAt, status, refundedOnly, deliveryType, paymentMethod) = + await ParseFilterAsync(request, cancellationToken); + + var result = await mediator.Send(new ExportOrderAllCsvQuery + { + StoreId = storeId, + StartAt = startAt, + EndAt = endAt, + Status = status, + RefundedOnly = refundedOnly, + DeliveryType = deliveryType, + PaymentMethod = paymentMethod, + Keyword = request.Keyword + }, cancellationToken); + + return ApiResponse.Ok(new OrderAllExportResponse + { + FileName = result.FileName, + FileContentBase64 = result.FileContentBase64, + TotalCount = result.TotalCount + }); + } + + private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, OrderStatus? Status, bool RefundedOnly, DeliveryType? DeliveryType, PaymentMethod? PaymentMethod)> ParseFilterAsync( + OrderAllFilterRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var startAt = ParseDateOrNull(request.StartDate); + var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1); + if (startAt.HasValue && endAt.HasValue && startAt >= endAt) + { + throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期"); + } + + var (status, refundedOnly) = ParseStatus(request.Status); + var deliveryType = ParseDeliveryType(request.Channel); + if (deliveryType is null) + { + var normalizedStatus = (request.Status ?? string.Empty).Trim().ToLowerInvariant(); + if (normalizedStatus == "pickup") + { + deliveryType = Domain.Orders.Enums.DeliveryType.Pickup; + } + else if (normalizedStatus == "delivering") + { + deliveryType = Domain.Orders.Enums.DeliveryType.Delivery; + } + } + var paymentMethod = ParsePaymentMethod(request.PaymentMethod); + + return (storeId, startAt, endAt, status, refundedOnly, deliveryType, paymentMethod); + } + + private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken) + { + var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken); + } + + private static DateTime? ParseDateOrNull(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (DateTime.TryParseExact( + value, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var parsed)) + { + return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc); + } + + throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd"); + } + + private static (OrderStatus? Status, bool RefundedOnly) ParseStatus(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "pending" => (OrderStatus.AwaitingPreparation, false), + "making" => (OrderStatus.InProgress, false), + "delivering" => (OrderStatus.Ready, false), + "pickup" => (OrderStatus.Ready, false), + "completed" => (OrderStatus.Completed, false), + "cancelled" => (OrderStatus.Cancelled, false), + "refunded" => (null, true), + _ => (null, false) + }; + } + + private static DeliveryType? ParseDeliveryType(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "delivery" => Domain.Orders.Enums.DeliveryType.Delivery, + "pickup" => Domain.Orders.Enums.DeliveryType.Pickup, + "dine_in" => Domain.Orders.Enums.DeliveryType.DineIn, + _ => null + }; + } + + private static PaymentMethod? ParsePaymentMethod(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "wechat" => Domain.Payments.Enums.PaymentMethod.WeChatPay, + "alipay" => Domain.Payments.Enums.PaymentMethod.Alipay, + "balance" => Domain.Payments.Enums.PaymentMethod.Balance, + "cash" => Domain.Payments.Enums.PaymentMethod.Cash, + "card" => Domain.Payments.Enums.PaymentMethod.Card, + _ => null + }; + } + + private static OrderAllListItemResponse MapListItem(OrderAllListItemDto source) + { + return new OrderAllListItemResponse + { + OrderNo = source.OrderNo, + OrderedAt = source.OrderedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + Channel = ToDeliveryTypeText(source.DeliveryType), + Customer = source.CustomerName, + ItemsSummary = source.ItemsSummary, + Amount = source.Amount, + Status = ToStatusText(source.Status, source.IsRefunded, source.DeliveryType), + IsDimmed = source.IsDimmed + }; + } + + private static OrderAllDetailResponse MapDetail(OrderAllDetailDto source) + { + return new OrderAllDetailResponse + { + OrderNo = source.OrderNo, + Channel = ToDeliveryTypeText(source.DeliveryType), + Status = ToStatusText(source.Status, false, source.DeliveryType), + PaymentMethod = ToPaymentMethodText(source.PaymentMethod), + OrderedAt = source.OrderedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + PaidAt = source.PaidAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + FinishedAt = source.FinishedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + CustomerName = source.CustomerName, + CustomerPhone = source.CustomerPhone, + CustomerAddress = source.CustomerAddress, + ItemsAmount = source.ItemsAmount, + DeliveryFee = source.DeliveryFee, + DiscountAmount = source.DiscountAmount, + PaidAmount = source.PaidAmount, + Remark = source.Remark, + Items = source.Items + .Select(item => new OrderAllDetailItemResponse + { + Name = item.Name, + Spec = item.Spec, + Quantity = item.Quantity, + UnitPrice = item.UnitPrice, + SubTotal = item.SubTotal + }) + .ToList(), + Timeline = source.Timeline + .OrderBy(item => item.OccurredAt) + .Select(item => new OrderAllTimelineResponse + { + Label = item.Label, + Time = item.OccurredAt.ToString("HH:mm:ss", CultureInfo.InvariantCulture) + }) + .ToList() + }; + } + + private static string ToDeliveryTypeText(Domain.Orders.Enums.DeliveryType value) + { + return value switch + { + Domain.Orders.Enums.DeliveryType.Delivery => "外卖", + Domain.Orders.Enums.DeliveryType.Pickup => "自提", + Domain.Orders.Enums.DeliveryType.DineIn => "堂食", + _ => "未知" + }; + } + + private static string ToPaymentMethodText(Domain.Payments.Enums.PaymentMethod value) + { + return value switch + { + Domain.Payments.Enums.PaymentMethod.WeChatPay => "微信支付", + Domain.Payments.Enums.PaymentMethod.Alipay => "支付宝", + Domain.Payments.Enums.PaymentMethod.Balance => "余额支付", + Domain.Payments.Enums.PaymentMethod.Cash => "现金", + Domain.Payments.Enums.PaymentMethod.Card => "刷卡", + _ => "--" + }; + } + + private static string ToStatusText(OrderStatus status, bool refunded, DeliveryType deliveryType) + { + if (refunded) + { + 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 => "待取餐", + _ => "待处理" + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderAllDtos.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderAllDtos.cs new file mode 100644 index 0000000..ba04237 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderAllDtos.cs @@ -0,0 +1,265 @@ +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Application.App.Orders.Dto; + +/// +/// 全部订单列表行 DTO。 +/// +public sealed class OrderAllListItemDto +{ + /// + /// 订单 ID。 + /// + public long OrderId { get; init; } + + /// + /// 订单号。 + /// + public string OrderNo { get; init; } = string.Empty; + + /// + /// 下单时间。 + /// + public DateTime OrderedAt { get; init; } + + /// + /// 履约方式。 + /// + public DeliveryType DeliveryType { get; init; } + + /// + /// 订单状态。 + /// + public OrderStatus Status { get; init; } + + /// + /// 顾客名称。 + /// + public string CustomerName { get; init; } = string.Empty; + + /// + /// 商品摘要。 + /// + public string ItemsSummary { get; init; } = string.Empty; + + /// + /// 金额。 + /// + public decimal Amount { get; init; } + + /// + /// 是否已退款。 + /// + public bool IsRefunded { get; init; } + + /// + /// 是否弱化显示。 + /// + public bool IsDimmed { get; init; } +} + +/// +/// 全部订单统计 DTO。 +/// +public sealed class OrderAllStatsDto +{ + /// + /// 总订单数。 + /// + public int TotalOrders { get; init; } + + /// + /// 总金额。 + /// + public decimal TotalAmount { get; init; } + + /// + /// 平均客单价。 + /// + public decimal AverageAmount { get; init; } + + /// + /// 退款单数。 + /// + public int RefundCount { get; init; } +} + +/// +/// 全部订单详情 DTO。 +/// +public sealed class OrderAllDetailDto +{ + /// + /// 订单 ID。 + /// + public long OrderId { get; init; } + + /// + /// 订单号。 + /// + public string OrderNo { get; init; } = string.Empty; + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 履约方式。 + /// + public DeliveryType DeliveryType { get; init; } + + /// + /// 订单状态。 + /// + public OrderStatus Status { get; init; } + + /// + /// 支付方式。 + /// + public PaymentMethod PaymentMethod { get; init; } + + /// + /// 顾客姓名。 + /// + public string CustomerName { get; init; } = string.Empty; + + /// + /// 顾客手机号。 + /// + public string CustomerPhone { get; init; } = string.Empty; + + /// + /// 顾客地址。 + /// + public string CustomerAddress { get; init; } = string.Empty; + + /// + /// 商品金额。 + /// + public decimal ItemsAmount { get; init; } + + /// + /// 配送费。 + /// + public decimal DeliveryFee { get; init; } + + /// + /// 优惠金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 实付金额。 + /// + public decimal PaidAmount { get; init; } + + /// + /// 应付金额。 + /// + public decimal PayableAmount { get; init; } + + /// + /// 下单时间。 + /// + public DateTime OrderedAt { get; init; } + + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; init; } + + /// + /// 完成时间。 + /// + public DateTime? FinishedAt { get; init; } + + /// + /// 取消时间。 + /// + public DateTime? CancelledAt { get; init; } + + /// + /// 备注。 + /// + public string Remark { get; init; } = string.Empty; + + /// + /// 商品明细。 + /// + public IReadOnlyList Items { get; init; } = []; + + /// + /// 时间线。 + /// + public IReadOnlyList Timeline { get; init; } = []; +} + +/// +/// 全部订单详情商品行 DTO。 +/// +public sealed class OrderAllDetailItemDto +{ + /// + /// 商品名。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 规格。 + /// + public string Spec { get; init; } = string.Empty; + + /// + /// 数量。 + /// + public int Quantity { get; init; } + + /// + /// 单价。 + /// + public decimal UnitPrice { get; init; } + + /// + /// 小计。 + /// + public decimal SubTotal { get; init; } +} + +/// +/// 全部订单时间线节点 DTO。 +/// +public sealed class OrderAllTimelineNodeDto +{ + /// + /// 节点名称。 + /// + public string Label { get; init; } = string.Empty; + + /// + /// 发生时间。 + /// + public DateTime OccurredAt { get; init; } +} + +/// +/// 全部订单导出 DTO。 +/// +public sealed class OrderAllExportDto +{ + /// + /// 文件名。 + /// + public string FileName { get; init; } = string.Empty; + + /// + /// 文件 Base64。 + /// + public string FileContentBase64 { get; init; } = string.Empty; + + /// + /// 总记录数。 + /// + public int TotalCount { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/ExportOrderAllCsvQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/ExportOrderAllCsvQueryHandler.cs new file mode 100644 index 0000000..bfc5b72 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/ExportOrderAllCsvQueryHandler.cs @@ -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; + +/// +/// 全部订单 CSV 导出处理器。 +/// +public sealed class ExportOrderAllCsvQueryHandler( + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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> LoadRefundedOrderIdsAsync( + IReadOnlyCollection 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 orders, + IReadOnlySet refundedIds, + IReadOnlyDictionary> 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> 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("\"", "\"\"")}\""; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderAllDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderAllDetailQueryHandler.cs new file mode 100644 index 0000000..b8f18c9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderAllDetailQueryHandler.cs @@ -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; + +/// +/// 全部订单详情查询处理器。 +/// +public sealed class GetOrderAllDetailQueryHandler( + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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 BuildTimeline( + Order order, + IReadOnlyList 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 + { + 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 => "待取餐", + _ => "待处理" + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderAllStatsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderAllStatsQueryHandler.cs new file mode 100644 index 0000000..005ffad --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderAllStatsQueryHandler.cs @@ -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; + +/// +/// 全部订单统计查询处理器。 +/// +public sealed class GetOrderAllStatsQueryHandler( + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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> LoadRefundedOrderIdsAsync( + IReadOnlyCollection 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; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrderAllListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrderAllListQueryHandler.cs new file mode 100644 index 0000000..6eda490 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrderAllListQueryHandler.cs @@ -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; + +/// +/// 全部订单列表查询处理器。 +/// +public sealed class SearchOrderAllListQueryHandler( + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> 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(rows, page, pageSize, filteredOrders.Count); + } + + private async Task> LoadRefundedOrderIdsAsync( + IReadOnlyCollection 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> 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 ApplySorting( + IReadOnlyCollection 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) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Queries/ExportOrderAllCsvQuery.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/ExportOrderAllCsvQuery.cs new file mode 100644 index 0000000..33ab0dc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/ExportOrderAllCsvQuery.cs @@ -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; + +/// +/// 全部订单 CSV 导出查询。 +/// +public sealed class ExportOrderAllCsvQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long? StoreId { get; init; } + + /// + /// 开始时间(含)。 + /// + public DateTime? StartAt { get; init; } + + /// + /// 结束时间(不含)。 + /// + public DateTime? EndAt { get; init; } + + /// + /// 状态。 + /// + public OrderStatus? Status { get; init; } + + /// + /// 是否仅退款。 + /// + public bool RefundedOnly { get; init; } + + /// + /// 履约方式。 + /// + public DeliveryType? DeliveryType { get; init; } + + /// + /// 支付方式。 + /// + public PaymentMethod? PaymentMethod { get; init; } + + /// + /// 关键词。 + /// + public string? Keyword { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Queries/GetOrderAllDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/GetOrderAllDetailQuery.cs new file mode 100644 index 0000000..7c24b89 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/GetOrderAllDetailQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Orders.Dto; + +namespace TakeoutSaaS.Application.App.Orders.Queries; + +/// +/// 全部订单详情查询。 +/// +public sealed class GetOrderAllDetailQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long? StoreId { get; init; } + + /// + /// 订单号。 + /// + public string OrderNo { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Queries/GetOrderAllStatsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/GetOrderAllStatsQuery.cs new file mode 100644 index 0000000..ffc3403 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/GetOrderAllStatsQuery.cs @@ -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; + +/// +/// 全部订单统计查询。 +/// +public sealed class GetOrderAllStatsQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long? StoreId { get; init; } + + /// + /// 开始时间(含)。 + /// + public DateTime? StartAt { get; init; } + + /// + /// 结束时间(不含)。 + /// + public DateTime? EndAt { get; init; } + + /// + /// 状态。 + /// + public OrderStatus? Status { get; init; } + + /// + /// 是否仅退款。 + /// + public bool RefundedOnly { get; init; } + + /// + /// 履约方式。 + /// + public DeliveryType? DeliveryType { get; init; } + + /// + /// 支付方式。 + /// + public PaymentMethod? PaymentMethod { get; init; } + + /// + /// 关键词。 + /// + public string? Keyword { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Queries/SearchOrderAllListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/SearchOrderAllListQuery.cs new file mode 100644 index 0000000..cc982bb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/SearchOrderAllListQuery.cs @@ -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; + +/// +/// 全部订单列表查询。 +/// +public sealed class SearchOrderAllListQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long? StoreId { get; init; } + + /// + /// 开始时间(含)。 + /// + public DateTime? StartAt { get; init; } + + /// + /// 结束时间(不含)。 + /// + public DateTime? EndAt { get; init; } + + /// + /// 状态。 + /// + public OrderStatus? Status { get; init; } + + /// + /// 是否仅退款。 + /// + public bool RefundedOnly { get; init; } + + /// + /// 履约方式。 + /// + public DeliveryType? DeliveryType { get; init; } + + /// + /// 支付方式。 + /// + public PaymentMethod? PaymentMethod { get; init; } + + /// + /// 关键词(订单号/手机号)。 + /// + public string? Keyword { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段。 + /// + public string? SortBy { get; init; } + + /// + /// 是否倒序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Validators/ExportOrderAllCsvQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/ExportOrderAllCsvQueryValidator.cs new file mode 100644 index 0000000..e6dcd7e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/ExportOrderAllCsvQueryValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Orders.Queries; + +namespace TakeoutSaaS.Application.App.Orders.Validators; + +/// +/// 全部订单导出查询验证器。 +/// +public sealed class ExportOrderAllCsvQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ExportOrderAllCsvQueryValidator() + { + RuleFor(x => x.Keyword).MaximumLength(64); + RuleFor(x => x) + .Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt) + .WithMessage("开始时间必须早于结束时间"); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Validators/GetOrderAllDetailQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/GetOrderAllDetailQueryValidator.cs new file mode 100644 index 0000000..c78ec5d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/GetOrderAllDetailQueryValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Orders.Queries; + +namespace TakeoutSaaS.Application.App.Orders.Validators; + +/// +/// 全部订单详情查询验证器。 +/// +public sealed class GetOrderAllDetailQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public GetOrderAllDetailQueryValidator() + { + RuleFor(x => x.OrderNo).NotEmpty().MaximumLength(32); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Validators/GetOrderAllStatsQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/GetOrderAllStatsQueryValidator.cs new file mode 100644 index 0000000..e958a67 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/GetOrderAllStatsQueryValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Orders.Queries; + +namespace TakeoutSaaS.Application.App.Orders.Validators; + +/// +/// 全部订单统计查询验证器。 +/// +public sealed class GetOrderAllStatsQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public GetOrderAllStatsQueryValidator() + { + RuleFor(x => x.Keyword).MaximumLength(64); + RuleFor(x => x) + .Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt) + .WithMessage("开始时间必须早于结束时间"); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Validators/SearchOrderAllListQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/SearchOrderAllListQueryValidator.cs new file mode 100644 index 0000000..07d45d8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/SearchOrderAllListQueryValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Orders.Queries; + +namespace TakeoutSaaS.Application.App.Orders.Validators; + +/// +/// 全部订单列表查询验证器。 +/// +public sealed class SearchOrderAllListQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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("开始时间必须早于结束时间"); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs index 96a3bd6..6e6510e 100644 --- a/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs @@ -1,5 +1,7 @@ using TakeoutSaaS.Domain.Orders.Entities; using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Deliveries.Entities; +using TakeoutSaaS.Domain.Payments.Entities; using TakeoutSaaS.Domain.Payments.Enums; namespace TakeoutSaaS.Domain.Orders.Repositories; @@ -37,6 +39,30 @@ public interface IOrderRepository /// 订单集合。 Task> SearchAsync(long tenantId, OrderStatus? status, PaymentStatus? paymentStatus, CancellationToken cancellationToken = default); + /// + /// 全部订单列表筛选查询。 + /// + /// 租户 ID。 + /// 门店 ID。 + /// 开始时间(含)。 + /// 结束时间(不含)。 + /// 订单状态。 + /// 履约方式。 + /// 支付方式。 + /// 关键词(订单号/手机号)。 + /// 取消标记。 + /// 订单集合。 + Task> SearchAllOrdersAsync( + long tenantId, + long? storeId, + DateTime? startAt, + DateTime? endAt, + OrderStatus? status, + DeliveryType? deliveryType, + PaymentMethod? paymentMethod, + string? keyword, + CancellationToken cancellationToken = default); + /// /// 获取订单明细行。 /// @@ -46,6 +72,18 @@ public interface IOrderRepository /// 订单明细集合。 Task> GetItemsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 按订单集合读取明细。 + /// + /// 订单 ID 集合。 + /// 租户 ID。 + /// 取消标记。 + /// 订单明细字典。 + Task>> GetItemsByOrderIdsAsync( + IReadOnlyCollection orderIds, + long tenantId, + CancellationToken cancellationToken = default); + /// /// 获取订单状态流转记录。 /// @@ -64,6 +102,36 @@ public interface IOrderRepository /// 退款申请列表。 Task> GetRefundsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 按订单集合查询已退款订单 ID。 + /// + /// 订单 ID 集合。 + /// 租户 ID。 + /// 取消标记。 + /// 已退款订单 ID 集合。 + Task> GetRefundedOrderIdsAsync( + IReadOnlyCollection orderIds, + long tenantId, + CancellationToken cancellationToken = default); + + /// + /// 获取订单最近支付记录。 + /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 支付记录或 null。 + Task GetLatestPaymentRecordAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取订单最近配送单。 + /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 配送单或 null。 + Task GetLatestDeliveryOrderAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); + /// /// 新增订单。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs index ba663d4..aac5988 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs @@ -1,7 +1,9 @@ using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Deliveries.Entities; using TakeoutSaaS.Domain.Orders.Entities; using TakeoutSaaS.Domain.Orders.Enums; using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Domain.Payments.Entities; using TakeoutSaaS.Domain.Payments.Enums; using TakeoutSaaS.Infrastructure.App.Persistence; @@ -57,6 +59,70 @@ public sealed class EfOrderRepository(TakeoutAppDbContext context) : IOrderRepos return orders; } + /// + public async Task> SearchAllOrdersAsync( + long tenantId, + long? storeId, + DateTime? startAt, + DateTime? endAt, + OrderStatus? status, + DeliveryType? deliveryType, + PaymentMethod? paymentMethod, + string? keyword, + CancellationToken cancellationToken = default) + { + var query = context.Orders + .AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (storeId.HasValue) + { + query = query.Where(x => x.StoreId == storeId.Value); + } + + if (startAt.HasValue) + { + query = query.Where(x => x.CreatedAt >= startAt.Value); + } + + if (endAt.HasValue) + { + query = query.Where(x => x.CreatedAt < endAt.Value); + } + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + if (deliveryType.HasValue) + { + query = query.Where(x => x.DeliveryType == deliveryType.Value); + } + + if (paymentMethod.HasValue) + { + query = query.Where(x => + context.PaymentRecords.Any(record => + record.TenantId == tenantId && + record.OrderId == x.Id && + record.Method == paymentMethod.Value)); + } + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(x => + x.OrderNo.Contains(normalized) || + (x.CustomerPhone != null && x.CustomerPhone.Contains(normalized))); + } + + return await query + .OrderByDescending(x => x.CreatedAt) + .ThenByDescending(x => x.Id) + .ToListAsync(cancellationToken); + } + /// public async Task> GetItemsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) { @@ -69,6 +135,29 @@ public sealed class EfOrderRepository(TakeoutAppDbContext context) : IOrderRepos return items; } + /// + public async Task>> GetItemsByOrderIdsAsync( + IReadOnlyCollection orderIds, + long tenantId, + CancellationToken cancellationToken = default) + { + if (orderIds.Count == 0) + { + return new Dictionary>(); + } + + var items = await context.OrderItems + .AsNoTracking() + .Where(x => x.TenantId == tenantId && orderIds.Contains(x.OrderId)) + .OrderBy(x => x.OrderId) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + + return items + .GroupBy(item => item.OrderId) + .ToDictionary(group => group.Key, group => (IReadOnlyList)group.ToList()); + } + /// public async Task> GetStatusHistoryAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) { @@ -93,6 +182,55 @@ public sealed class EfOrderRepository(TakeoutAppDbContext context) : IOrderRepos return refunds; } + /// + public async Task> GetRefundedOrderIdsAsync( + IReadOnlyCollection orderIds, + long tenantId, + CancellationToken cancellationToken = default) + { + if (orderIds.Count == 0) + { + return new HashSet(); + } + + var result = await context.RefundRequests + .AsNoTracking() + .Where(x => + x.TenantId == tenantId && + orderIds.Contains(x.OrderId) && + x.Status == RefundStatus.Refunded) + .Select(x => x.OrderId) + .Distinct() + .ToListAsync(cancellationToken); + + return result.ToHashSet(); + } + + /// + public Task GetLatestPaymentRecordAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) + { + return context.PaymentRecords + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.OrderId == orderId) + .OrderByDescending(x => x.PaidAt ?? DateTime.MinValue) + .ThenByDescending(x => x.CreatedAt) + .ThenByDescending(x => x.Id) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task GetLatestDeliveryOrderAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) + { + return context.DeliveryOrders + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.OrderId == orderId) + .OrderByDescending(x => x.DeliveredAt ?? DateTime.MinValue) + .ThenByDescending(x => x.PickedUpAt ?? DateTime.MinValue) + .ThenByDescending(x => x.CreatedAt) + .ThenByDescending(x => x.Id) + .FirstOrDefaultAsync(cancellationToken); + } + /// public Task AddOrderAsync(Order order, CancellationToken cancellationToken = default) {