feat(pos): implement table-based ordering, kitchen ticket workflow, and table floor plan management
This commit is contained in:
@@ -12,5 +12,7 @@ namespace FnbEngine.API.Application.Commands;
|
||||
public record UpdateTableCommand(
|
||||
Guid TableId,
|
||||
int? Capacity = null,
|
||||
string? Zone = null
|
||||
string? Zone = null,
|
||||
int? PositionX = null,
|
||||
int? PositionY = null
|
||||
) : IRequest<bool>;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// EN: Handler for UpdateTableCommand.
|
||||
// VI: Handler cho UpdateTableCommand.
|
||||
|
||||
using MediatR;
|
||||
using FnbEngine.Domain.AggregatesModel.TableAggregate;
|
||||
|
||||
namespace FnbEngine.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for updating table details.
|
||||
/// VI: Handler cập nhật thông tin bàn.
|
||||
/// </summary>
|
||||
public class UpdateTableCommandHandler : IRequestHandler<UpdateTableCommand, bool>
|
||||
{
|
||||
private readonly ITableRepository _tableRepository;
|
||||
private readonly ILogger<UpdateTableCommandHandler> _logger;
|
||||
|
||||
public UpdateTableCommandHandler(
|
||||
ITableRepository tableRepository,
|
||||
ILogger<UpdateTableCommandHandler> logger)
|
||||
{
|
||||
_tableRepository = tableRepository ?? throw new ArgumentNullException(nameof(tableRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(UpdateTableCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Updating table {TableId}", request.TableId);
|
||||
|
||||
var table = await _tableRepository.GetByIdAsync(request.TableId, cancellationToken);
|
||||
if (table == null)
|
||||
throw new InvalidOperationException($"Table not found: {request.TableId}");
|
||||
|
||||
if (request.PositionX.HasValue && request.PositionY.HasValue)
|
||||
table.SetPosition(request.PositionX.Value, request.PositionY.Value);
|
||||
|
||||
_tableRepository.Update(table);
|
||||
await _tableRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Updated table {TableId}", request.TableId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,7 @@ public record TableDto(
|
||||
string TableNumber,
|
||||
int Capacity,
|
||||
string? Zone,
|
||||
string Status
|
||||
string Status,
|
||||
int? PositionX = null,
|
||||
int? PositionY = null
|
||||
);
|
||||
|
||||
@@ -33,7 +33,9 @@ public class GetTablesQueryHandler : IRequestHandler<GetTablesQuery, IEnumerable
|
||||
t.TableNumber,
|
||||
t.Capacity,
|
||||
t.Zone,
|
||||
allStatuses.TryGetValue(t.StatusId, out var name) ? name : "available"
|
||||
allStatuses.TryGetValue(t.StatusId, out var name) ? name : "available",
|
||||
t.PositionX,
|
||||
t.PositionY
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,29 @@ public class TablesController : ControllerBase
|
||||
new ApiResponse<CreateTableResult> { Success = true, Data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update table details.
|
||||
/// VI: Cập nhật thông tin bàn.
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<ApiResponse<bool>>> UpdateTable(
|
||||
Guid id,
|
||||
[FromBody] UpdateTableRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var command = new UpdateTableCommand(
|
||||
id,
|
||||
request.Capacity,
|
||||
request.Zone,
|
||||
request.PositionX,
|
||||
request.PositionY);
|
||||
|
||||
var result = await _mediator.Send(command, ct);
|
||||
return Ok(new ApiResponse<bool> { Success = true, Data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Change table status.
|
||||
/// VI: Đổi trạng thái bàn.
|
||||
@@ -91,6 +114,16 @@ public record CreateTableRequest(
|
||||
int Capacity,
|
||||
string? Zone = null);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request to update table details.
|
||||
/// VI: Request cập nhật thông tin bàn.
|
||||
/// </summary>
|
||||
public record UpdateTableRequest(
|
||||
int? Capacity = null,
|
||||
string? Zone = null,
|
||||
int? PositionX = null,
|
||||
int? PositionY = null);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request to change table status.
|
||||
/// VI: Request đổi trạng thái bàn.
|
||||
|
||||
@@ -17,6 +17,8 @@ public class Table : Entity, IAggregateRoot
|
||||
private int _capacity;
|
||||
private string? _zone;
|
||||
private TableStatus _status = null!;
|
||||
private int? _positionX;
|
||||
private int? _positionY;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
@@ -26,6 +28,8 @@ public class Table : Entity, IAggregateRoot
|
||||
public string? Zone => _zone;
|
||||
public TableStatus Status => _status;
|
||||
public int StatusId { get; private set; }
|
||||
public int? PositionX => _positionX;
|
||||
public int? PositionY => _positionY;
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
@@ -75,4 +79,11 @@ public class Table : Entity, IAggregateRoot
|
||||
StatusId = TableStatus.Cleaning.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void SetPosition(int x, int y)
|
||||
{
|
||||
_positionX = x;
|
||||
_positionY = y;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ public class TableEntityTypeConfiguration : IEntityTypeConfiguration<Table>
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(t => t.PositionX)
|
||||
.HasField("_positionX")
|
||||
.HasColumnName("position_x");
|
||||
|
||||
builder.Property(t => t.PositionY)
|
||||
.HasField("_positionY")
|
||||
.HasColumnName("position_y");
|
||||
|
||||
builder.Property(t => t.CreatedAt)
|
||||
.HasField("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
|
||||
@@ -15,7 +15,8 @@ public record CreateOrderCommand(
|
||||
List<OrderItemRequest> Items,
|
||||
decimal? DiscountAmount = null,
|
||||
string? DiscountType = null,
|
||||
string? DiscountReference = null
|
||||
string? DiscountReference = null,
|
||||
Guid? TableId = null
|
||||
) : IRequest<CreateOrderResult>;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -37,7 +37,7 @@ public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Cre
|
||||
|
||||
// EN: Create order aggregate
|
||||
// VI: Tạo order aggregate
|
||||
var order = new Order(request.ShopId, request.CustomerId);
|
||||
var order = new Order(request.ShopId, request.CustomerId, request.TableId);
|
||||
|
||||
// EN: Add items to order
|
||||
// VI: Thêm items vào order
|
||||
|
||||
@@ -42,6 +42,7 @@ public record OrderSummaryDto
|
||||
public Guid Id { get; init; }
|
||||
public Guid ShopId { get; init; }
|
||||
public Guid? CustomerId { get; init; }
|
||||
public Guid? TableId { get; init; }
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public decimal TotalAmount { get; init; }
|
||||
public long ItemCount { get; init; }
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// EN: Query to get active (unpaid) orders for a shop, grouped by table — includes items.
|
||||
// VI: Query lấy orders chưa thanh toán của shop, nhóm theo bàn — bao gồm items.
|
||||
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using MediatR;
|
||||
using OrderService.API.Application.DTOs;
|
||||
|
||||
namespace OrderService.API.Application.Queries;
|
||||
|
||||
public record GetActiveTableOrdersQuery(Guid ShopId) : IRequest<List<ActiveTableOrderDto>>;
|
||||
|
||||
public record ActiveTableOrderDto
|
||||
{
|
||||
public Guid OrderId { get; init; }
|
||||
public Guid? TableId { get; init; }
|
||||
public decimal TotalAmount { get; init; }
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public List<ActiveTableOrderItemDto> Items { get; init; } = new();
|
||||
}
|
||||
|
||||
public record ActiveTableOrderItemDto
|
||||
{
|
||||
public Guid ProductId { get; init; }
|
||||
public string ProductName { get; init; } = string.Empty;
|
||||
public int Quantity { get; init; }
|
||||
public decimal UnitPrice { get; init; }
|
||||
}
|
||||
|
||||
public class GetActiveTableOrdersQueryHandler : IRequestHandler<GetActiveTableOrdersQuery, List<ActiveTableOrderDto>>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
public GetActiveTableOrdersQueryHandler(IDbConnection connection)
|
||||
{
|
||||
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
}
|
||||
|
||||
public async Task<List<ActiveTableOrderDto>> Handle(
|
||||
GetActiveTableOrdersQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// EN: Get active orders (status = Validated=2) with table_id for this shop
|
||||
// VI: Lấy orders đang active (status = Validated=2) có table_id cho shop này
|
||||
var ordersSql = @"
|
||||
SELECT o.id AS OrderId, o.table_id AS TableId, o.total_amount AS TotalAmount, o.created_at AS CreatedAt
|
||||
FROM orders o
|
||||
WHERE o.shop_id = @ShopId AND o.status_id = 2 AND o.table_id IS NOT NULL
|
||||
ORDER BY o.created_at";
|
||||
|
||||
var orders = (await _connection.QueryAsync<ActiveTableOrderDto>(ordersSql, new { request.ShopId })).ToList();
|
||||
if (!orders.Any()) return orders;
|
||||
|
||||
// EN: Get items for all active orders in one query
|
||||
// VI: Lấy items cho tất cả active orders trong 1 query
|
||||
var orderIds = orders.Select(o => o.OrderId).ToArray();
|
||||
var itemsSql = @"
|
||||
SELECT oi.order_id AS OrderId, oi.product_id AS ProductId, oi.product_name AS ProductName,
|
||||
oi.quantity AS Quantity, oi.unit_price AS UnitPrice
|
||||
FROM order_items oi
|
||||
WHERE oi.order_id = ANY(@OrderIds)";
|
||||
|
||||
var items = await _connection.QueryAsync<(Guid OrderId, Guid ProductId, string ProductName, int Quantity, decimal UnitPrice)>(
|
||||
itemsSql, new { OrderIds = orderIds });
|
||||
|
||||
var itemsByOrder = items.GroupBy(i => i.OrderId).ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
return orders.Select(o => o with
|
||||
{
|
||||
Items = itemsByOrder.TryGetValue(o.OrderId, out var orderItems)
|
||||
? orderItems.Select(i => new ActiveTableOrderItemDto
|
||||
{
|
||||
ProductId = i.ProductId,
|
||||
ProductName = i.ProductName,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = i.UnitPrice
|
||||
}).ToList()
|
||||
: new()
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ public class ListOrdersByShopQueryHandler : IRequestHandler<ListOrdersByShopQuer
|
||||
o.id AS Id,
|
||||
o.shop_id AS ShopId,
|
||||
o.customer_id AS CustomerId,
|
||||
o.table_id AS TableId,
|
||||
os.name AS Status,
|
||||
o.total_amount AS TotalAmount,
|
||||
o.created_at AS CreatedAt,
|
||||
|
||||
@@ -194,6 +194,21 @@ public class OrdersController : ControllerBase
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get active (unpaid) orders grouped by table for a shop.
|
||||
/// VI: Lấy orders chưa thanh toán nhóm theo bàn cho shop.
|
||||
/// </summary>
|
||||
[HttpGet("active-by-table")]
|
||||
[ProducesResponseType(typeof(List<ActiveTableOrderDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<ActiveTableOrderDto>>> GetActiveTableOrders(
|
||||
[FromQuery] Guid shopId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = new GetActiveTableOrdersQuery(shopId);
|
||||
var result = await _mediator.Send(query, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get orders by customer.
|
||||
/// VI: Lấy orders theo khách hàng.
|
||||
|
||||
@@ -15,6 +15,7 @@ public class Order : Entity, IAggregateRoot
|
||||
{
|
||||
private Guid _shopId;
|
||||
private Guid? _customerId;
|
||||
private Guid? _tableId;
|
||||
private OrderStatus _status = null!;
|
||||
private decimal _totalAmount;
|
||||
private DateTime _createdAt;
|
||||
@@ -38,6 +39,12 @@ public class Order : Entity, IAggregateRoot
|
||||
/// </summary>
|
||||
public Guid? CustomerId => _customerId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Table ID (optional — for dine-in restaurant orders).
|
||||
/// VI: ID bàn (tùy chọn — cho đơn hàng dine-in nhà hàng).
|
||||
/// </summary>
|
||||
public Guid? TableId => _tableId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Order status.
|
||||
/// VI: Trạng thái đơn hàng.
|
||||
@@ -90,7 +97,7 @@ public class Order : Entity, IAggregateRoot
|
||||
/// EN: Create a new order.
|
||||
/// VI: Tạo đơn hàng mới.
|
||||
/// </summary>
|
||||
public Order(Guid shopId, Guid? customerId = null)
|
||||
public Order(Guid shopId, Guid? customerId = null, Guid? tableId = null)
|
||||
{
|
||||
if (shopId == Guid.Empty)
|
||||
throw new DomainException("Shop ID cannot be empty");
|
||||
@@ -98,6 +105,7 @@ public class Order : Entity, IAggregateRoot
|
||||
Id = Guid.NewGuid();
|
||||
_shopId = shopId;
|
||||
_customerId = customerId;
|
||||
_tableId = tableId;
|
||||
_status = OrderStatus.Draft;
|
||||
StatusId = OrderStatus.Draft.Id;
|
||||
_totalAmount = 0;
|
||||
|
||||
@@ -30,6 +30,9 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
|
||||
builder.Property<Guid?>("_customerId")
|
||||
.HasColumnName("customer_id");
|
||||
|
||||
builder.Property<Guid?>("_tableId")
|
||||
.HasColumnName("table_id");
|
||||
|
||||
builder.Property(o => o.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
@@ -132,6 +135,7 @@ public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
|
||||
// VI: Bỏ qua các properties được tính toán
|
||||
builder.Ignore(o => o.ShopId);
|
||||
builder.Ignore(o => o.CustomerId);
|
||||
builder.Ignore(o => o.TableId);
|
||||
builder.Ignore(o => o.Status);
|
||||
builder.Ignore(o => o.TotalAmount);
|
||||
builder.Ignore(o => o.DiscountAmount);
|
||||
|
||||
Reference in New Issue
Block a user