feat: 添加订单大厅看板 API(四列数据 + 统计 + 重连补偿 + 操作端点)
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Has been cancelled
- 新增 OrderBoardCardDto、OrderBoardResultDto、OrderBoardStatsDto - 新增看板查询 Query + Handler(board/stats/pending-since) - IOrderRepository 扩展 GetActiveOrdersAsync、GetOrdersChangedSinceAsync - EfOrderRepository 实现看板查询方法 - 新增 OrderBoardController(GET board/stats/pending-since + POST accept/reject/complete/confirm) - 新增 RejectOrderRequest 契约
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.OrderBoard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 拒单请求体。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RejectOrderRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 拒单原因。
|
||||||
|
/// </summary>
|
||||||
|
public required string Reason { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
using Asp.Versioning;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TakeoutSaaS.Application.App.Orders.Commands;
|
||||||
|
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.Infrastructure.App.Persistence;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.TenantApi.Contracts.OrderBoard;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单大厅(实时看板)接口。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/tenant/v{version:apiVersion}/order-board")]
|
||||||
|
public sealed class OrderBoardController(
|
||||||
|
IMediator mediator,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService) : BaseApiController
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取完整看板数据(四列)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("board")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<OrderBoardResultDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<OrderBoardResultDto>> GetBoard(
|
||||||
|
[FromQuery] string storeId,
|
||||||
|
[FromQuery] string? channel,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 解析并校验门店权限
|
||||||
|
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||||
|
await EnsureStoreAccessibleAsync(parsedStoreId, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 解析渠道筛选
|
||||||
|
var channelFilter = ParseChannel(channel);
|
||||||
|
|
||||||
|
// 3. 查询看板数据
|
||||||
|
var (_, tenantId, _) = storeContextService.GetRequiredContext();
|
||||||
|
var result = await mediator.Send(new GetOrderBoardQuery
|
||||||
|
{
|
||||||
|
StoreId = parsedStoreId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Channel = channelFilter
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<OrderBoardResultDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取看板统计数据。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("stats")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<OrderBoardStatsDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<OrderBoardStatsDto>> GetStats(
|
||||||
|
[FromQuery] string storeId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||||
|
await EnsureStoreAccessibleAsync(parsedStoreId, cancellationToken);
|
||||||
|
|
||||||
|
var (_, tenantId, _) = storeContextService.GetRequiredContext();
|
||||||
|
var result = await mediator.Send(new GetOrderBoardStatsQuery
|
||||||
|
{
|
||||||
|
StoreId = parsedStoreId,
|
||||||
|
TenantId = tenantId
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<OrderBoardStatsDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 重连补偿拉取(获取指定时间后的订单变更)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("pending-since")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<OrderBoardCardDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<IReadOnlyList<OrderBoardCardDto>>> GetPendingSince(
|
||||||
|
[FromQuery] string storeId,
|
||||||
|
[FromQuery] DateTime since,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||||
|
await EnsureStoreAccessibleAsync(parsedStoreId, cancellationToken);
|
||||||
|
|
||||||
|
var (_, tenantId, _) = storeContextService.GetRequiredContext();
|
||||||
|
var result = await mediator.Send(new GetPendingOrdersSinceQuery
|
||||||
|
{
|
||||||
|
StoreId = parsedStoreId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Since = since
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<IReadOnlyList<OrderBoardCardDto>>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接单。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{orderId}/accept")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<OrderBoardCardDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<OrderBoardCardDto>> Accept(
|
||||||
|
string orderId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parsedOrderId = StoreApiHelpers.ParseRequiredSnowflake(orderId, nameof(orderId));
|
||||||
|
var (userId, tenantId, _) = storeContextService.GetRequiredContext();
|
||||||
|
|
||||||
|
var result = await mediator.Send(new AcceptOrderCommand
|
||||||
|
{
|
||||||
|
OrderId = parsedOrderId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
OperatorId = userId
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<OrderBoardCardDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 拒单。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{orderId}/reject")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<OrderBoardCardDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<OrderBoardCardDto>> Reject(
|
||||||
|
string orderId,
|
||||||
|
[FromBody] RejectOrderRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parsedOrderId = StoreApiHelpers.ParseRequiredSnowflake(orderId, nameof(orderId));
|
||||||
|
var (userId, tenantId, _) = storeContextService.GetRequiredContext();
|
||||||
|
|
||||||
|
var result = await mediator.Send(new RejectOrderCommand
|
||||||
|
{
|
||||||
|
OrderId = parsedOrderId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Reason = request.Reason,
|
||||||
|
OperatorId = userId
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<OrderBoardCardDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 出餐完成。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{orderId}/complete-preparation")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<OrderBoardCardDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<OrderBoardCardDto>> CompletePreparation(
|
||||||
|
string orderId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parsedOrderId = StoreApiHelpers.ParseRequiredSnowflake(orderId, nameof(orderId));
|
||||||
|
var (userId, tenantId, _) = storeContextService.GetRequiredContext();
|
||||||
|
|
||||||
|
var result = await mediator.Send(new CompletePreparationCommand
|
||||||
|
{
|
||||||
|
OrderId = parsedOrderId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
OperatorId = userId
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<OrderBoardCardDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 确认送达/取餐。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{orderId}/confirm-delivery")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<OrderBoardCardDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<OrderBoardCardDto>> ConfirmDelivery(
|
||||||
|
string orderId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parsedOrderId = StoreApiHelpers.ParseRequiredSnowflake(orderId, nameof(orderId));
|
||||||
|
var (userId, tenantId, _) = storeContextService.GetRequiredContext();
|
||||||
|
|
||||||
|
var result = await mediator.Send(new ConfirmDeliveryCommand
|
||||||
|
{
|
||||||
|
OrderId = parsedOrderId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
OperatorId = userId
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<OrderBoardCardDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrderChannel? ParseChannel(string? value)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"miniprogram" => OrderChannel.MiniProgram,
|
||||||
|
"scan" => OrderChannel.ScanToOrder,
|
||||||
|
"staff" => OrderChannel.StaffConsole,
|
||||||
|
"phone" => OrderChannel.PhoneReservation,
|
||||||
|
"thirdparty" => OrderChannel.ThirdPartyDelivery,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Orders.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单看板卡片 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrderBoardCardDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 订单标识。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单编号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下单渠道。
|
||||||
|
/// </summary>
|
||||||
|
public OrderChannel Channel { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 履约类型。
|
||||||
|
/// </summary>
|
||||||
|
public DeliveryType DeliveryType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当前状态。
|
||||||
|
/// </summary>
|
||||||
|
public OrderStatus Status { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 顾客姓名。
|
||||||
|
/// </summary>
|
||||||
|
public string? CustomerName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 顾客手机号。
|
||||||
|
/// </summary>
|
||||||
|
public string? CustomerPhone { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 桌号。
|
||||||
|
/// </summary>
|
||||||
|
public string? TableNo { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排队号。
|
||||||
|
/// </summary>
|
||||||
|
public string? QueueNumber { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品摘要。
|
||||||
|
/// </summary>
|
||||||
|
public string? ItemsSummary { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实付金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal PaidAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接单时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? AcceptedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 出餐时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? ReadyAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否被催单。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsUrged { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 催单次数。
|
||||||
|
/// </summary>
|
||||||
|
public int UrgeCount { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Orders.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单看板结果 DTO(四列数据)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrderBoardResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 待接单列表。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<OrderBoardCardDto> Pending { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 制作中列表。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<OrderBoardCardDto> Making { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配送/待取餐列表。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<OrderBoardCardDto> Delivering { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已完成列表(今日,限 20 条)。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<OrderBoardCardDto> Completed { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Orders.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单看板统计 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrderBoardStatsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 今日订单总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TodayTotal { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 待接单数。
|
||||||
|
/// </summary>
|
||||||
|
public int PendingCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 制作中数。
|
||||||
|
/// </summary>
|
||||||
|
public int MakingCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配送/待取餐数。
|
||||||
|
/// </summary>
|
||||||
|
public int DeliveringCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已完成数。
|
||||||
|
/// </summary>
|
||||||
|
public int CompletedCount { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Orders.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取订单看板数据查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetOrderBoardQueryHandler(IOrderRepository orderRepository)
|
||||||
|
: IRequestHandler<GetOrderBoardQuery, OrderBoardResultDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<OrderBoardResultDto> Handle(GetOrderBoardQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 查询活跃订单
|
||||||
|
var activeOrders = await orderRepository.GetActiveOrdersAsync(
|
||||||
|
request.TenantId, request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 查询今日已完成订单(限 20 条)
|
||||||
|
var todayStart = DateTime.UtcNow.Date;
|
||||||
|
var completedOrders = await orderRepository.GetOrdersChangedSinceAsync(
|
||||||
|
request.TenantId, request.StoreId, todayStart, cancellationToken);
|
||||||
|
var todayCompleted = completedOrders
|
||||||
|
.Where(o => o.Status == OrderStatus.Completed)
|
||||||
|
.OrderByDescending(o => o.FinishedAt)
|
||||||
|
.Take(20)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 3. 合并并按渠道筛选
|
||||||
|
var allOrders = activeOrders.Concat(todayCompleted).ToList();
|
||||||
|
if (request.Channel.HasValue)
|
||||||
|
{
|
||||||
|
allOrders = allOrders.Where(o => o.Channel == request.Channel.Value).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 获取商品摘要
|
||||||
|
var orderIds = allOrders.Select(o => o.Id).ToList();
|
||||||
|
var itemsMap = orderIds.Count > 0
|
||||||
|
? await orderRepository.GetItemsByOrderIdsAsync(orderIds, request.TenantId, cancellationToken)
|
||||||
|
: new Dictionary<long, IReadOnlyList<OrderItem>>();
|
||||||
|
|
||||||
|
// 5. 按状态分组
|
||||||
|
var cards = allOrders.Select(o => MapToCard(o, itemsMap)).ToList();
|
||||||
|
|
||||||
|
return new OrderBoardResultDto
|
||||||
|
{
|
||||||
|
Pending = cards.Where(c => c.Status == OrderStatus.AwaitingPreparation)
|
||||||
|
.OrderBy(c => c.CreatedAt).ToList(),
|
||||||
|
Making = cards.Where(c => c.Status == OrderStatus.InProgress)
|
||||||
|
.OrderBy(c => c.AcceptedAt).ToList(),
|
||||||
|
Delivering = cards.Where(c => c.Status == OrderStatus.Ready)
|
||||||
|
.OrderBy(c => c.ReadyAt).ToList(),
|
||||||
|
Completed = cards.Where(c => c.Status == OrderStatus.Completed)
|
||||||
|
.OrderByDescending(c => c.CreatedAt).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrderBoardCardDto MapToCard(
|
||||||
|
Order order,
|
||||||
|
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsMap)
|
||||||
|
{
|
||||||
|
var items = itemsMap.TryGetValue(order.Id, out var list) ? list : [];
|
||||||
|
var summary = items.Count > 0
|
||||||
|
? string.Join("、", items.Take(3).Select(x => x.ProductName))
|
||||||
|
+ (items.Count > 3 ? $" 等{items.Count}件" : string.Empty)
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
return new OrderBoardCardDto
|
||||||
|
{
|
||||||
|
Id = order.Id,
|
||||||
|
OrderNo = order.OrderNo,
|
||||||
|
StoreId = order.StoreId,
|
||||||
|
Channel = order.Channel,
|
||||||
|
DeliveryType = order.DeliveryType,
|
||||||
|
Status = order.Status,
|
||||||
|
CustomerName = order.CustomerName,
|
||||||
|
CustomerPhone = order.CustomerPhone,
|
||||||
|
TableNo = order.TableNo,
|
||||||
|
QueueNumber = order.QueueNumber,
|
||||||
|
ItemsSummary = summary,
|
||||||
|
PaidAmount = order.PaidAmount,
|
||||||
|
CreatedAt = order.CreatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Orders.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取订单看板统计查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetOrderBoardStatsQueryHandler(IOrderRepository orderRepository)
|
||||||
|
: IRequestHandler<GetOrderBoardStatsQuery, OrderBoardStatsDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<OrderBoardStatsDto> Handle(GetOrderBoardStatsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 查询活跃订单
|
||||||
|
var activeOrders = await orderRepository.GetActiveOrdersAsync(
|
||||||
|
request.TenantId, request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 查询今日所有订单
|
||||||
|
var todayStart = DateTime.UtcNow.Date;
|
||||||
|
var todayOrders = await orderRepository.GetOrdersChangedSinceAsync(
|
||||||
|
request.TenantId, request.StoreId, todayStart, cancellationToken);
|
||||||
|
|
||||||
|
// 3. 统计各状态数量
|
||||||
|
var allOrders = activeOrders.Concat(todayOrders).DistinctBy(o => o.Id).ToList();
|
||||||
|
|
||||||
|
return new OrderBoardStatsDto
|
||||||
|
{
|
||||||
|
TodayTotal = allOrders.Count,
|
||||||
|
PendingCount = allOrders.Count(o => o.Status == OrderStatus.AwaitingPreparation),
|
||||||
|
MakingCount = allOrders.Count(o => o.Status == OrderStatus.InProgress),
|
||||||
|
DeliveringCount = allOrders.Count(o => o.Status == OrderStatus.Ready),
|
||||||
|
CompletedCount = allOrders.Count(o => o.Status == OrderStatus.Completed)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Orders.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Orders.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定时间后的待处理订单查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetPendingOrdersSinceQueryHandler(IOrderRepository orderRepository)
|
||||||
|
: IRequestHandler<GetPendingOrdersSinceQuery, IReadOnlyList<OrderBoardCardDto>>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<OrderBoardCardDto>> Handle(
|
||||||
|
GetPendingOrdersSinceQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 查询指定时间后变更的订单
|
||||||
|
var orders = await orderRepository.GetOrdersChangedSinceAsync(
|
||||||
|
request.TenantId, request.StoreId, request.Since, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 获取商品摘要
|
||||||
|
var orderIds = orders.Select(o => o.Id).ToList();
|
||||||
|
var itemsMap = orderIds.Count > 0
|
||||||
|
? await orderRepository.GetItemsByOrderIdsAsync(orderIds, request.TenantId, cancellationToken)
|
||||||
|
: new Dictionary<long, IReadOnlyList<OrderItem>>();
|
||||||
|
|
||||||
|
// 3. 映射为卡片 DTO
|
||||||
|
return orders.Select(o =>
|
||||||
|
{
|
||||||
|
var items = itemsMap.TryGetValue(o.Id, out var list) ? list : [];
|
||||||
|
var summary = items.Count > 0
|
||||||
|
? string.Join("、", items.Take(3).Select(x => x.ProductName))
|
||||||
|
+ (items.Count > 3 ? $" 等{items.Count}件" : string.Empty)
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
return new OrderBoardCardDto
|
||||||
|
{
|
||||||
|
Id = o.Id,
|
||||||
|
OrderNo = o.OrderNo,
|
||||||
|
StoreId = o.StoreId,
|
||||||
|
Channel = o.Channel,
|
||||||
|
DeliveryType = o.DeliveryType,
|
||||||
|
Status = o.Status,
|
||||||
|
CustomerName = o.CustomerName,
|
||||||
|
CustomerPhone = o.CustomerPhone,
|
||||||
|
TableNo = o.TableNo,
|
||||||
|
QueueNumber = o.QueueNumber,
|
||||||
|
ItemsSummary = summary,
|
||||||
|
PaidAmount = o.PaidAmount,
|
||||||
|
CreatedAt = o.CreatedAt
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Orders.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取订单看板数据查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GetOrderBoardQuery : IRequest<OrderBoardResultDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public required long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户标识。
|
||||||
|
/// </summary>
|
||||||
|
public required long TenantId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道筛选(可选)。
|
||||||
|
/// </summary>
|
||||||
|
public OrderChannel? Channel { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Orders.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取订单看板统计查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GetOrderBoardStatsQuery : IRequest<OrderBoardStatsDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public required long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户标识。
|
||||||
|
/// </summary>
|
||||||
|
public required long TenantId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Orders.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Orders.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定时间后的待处理订单(重连补偿)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GetPendingOrdersSinceQuery : IRequest<IReadOnlyList<OrderBoardCardDto>>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public required long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户标识。
|
||||||
|
/// </summary>
|
||||||
|
public required long TenantId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 起始时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public required DateTime Since { get; init; }
|
||||||
|
}
|
||||||
@@ -221,4 +221,23 @@ public interface IOrderRepository
|
|||||||
/// <param name="cancellationToken">取消标记。</param>
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
/// <returns>异步任务。</returns>
|
/// <returns>异步任务。</returns>
|
||||||
Task DeleteOrderAsync(long orderId, long tenantId, CancellationToken cancellationToken = default);
|
Task DeleteOrderAsync(long orderId, long tenantId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取门店活跃订单(待接单、制作中、待取餐)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tenantId">租户 ID。</param>
|
||||||
|
/// <param name="storeId">门店 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>活跃订单集合。</returns>
|
||||||
|
Task<IReadOnlyList<Order>> GetActiveOrdersAsync(long tenantId, long storeId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定时间后变更的订单(重连补偿)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tenantId">租户 ID。</param>
|
||||||
|
/// <param name="storeId">门店 ID。</param>
|
||||||
|
/// <param name="since">起始时间(UTC)。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>变更订单集合。</returns>
|
||||||
|
Task<IReadOnlyList<Order>> GetOrdersChangedSinceAsync(long tenantId, long storeId, DateTime since, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,6 +313,35 @@ public sealed class EfOrderRepository(TakeoutAppDbContext context) : IOrderRepos
|
|||||||
context.Orders.Remove(existing);
|
context.Orders.Remove(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<Order>> GetActiveOrdersAsync(long tenantId, long storeId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 查询待接单、制作中、待取餐的活跃订单
|
||||||
|
var activeStatuses = new[]
|
||||||
|
{
|
||||||
|
OrderStatus.AwaitingPreparation,
|
||||||
|
OrderStatus.InProgress,
|
||||||
|
OrderStatus.Ready
|
||||||
|
};
|
||||||
|
|
||||||
|
return await context.Orders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && activeStatuses.Contains(x.Status))
|
||||||
|
.OrderBy(x => x.CreatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<Order>> GetOrdersChangedSinceAsync(long tenantId, long storeId, DateTime since, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 查询指定时间后创建或更新的订单
|
||||||
|
return await context.Orders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.UpdatedAt >= since)
|
||||||
|
.OrderByDescending(x => x.UpdatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private IQueryable<Order> BuildSearchAllOrdersQuery(
|
private IQueryable<Order> BuildSearchAllOrdersQuery(
|
||||||
long tenantId,
|
long tenantId,
|
||||||
long? storeId,
|
long? storeId,
|
||||||
|
|||||||
Reference in New Issue
Block a user