feat: 添加订单大厅看板 API(四列数据 + 统计 + 重连补偿 + 操作端点)
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:
2026-02-27 13:10:13 +08:00
parent 1e5f0b2f93
commit 3c423f87d4
13 changed files with 684 additions and 0 deletions

View File

@@ -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; }
}

View File

@@ -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; } = [];
}

View File

@@ -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; }
}

View File

@@ -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
};
}
}

View File

@@ -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)
};
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}