feat: Implement new API endpoints, application logic, and domain repositories across FnbEngine, BookingService, and OrderService, alongside minor infrastructure updates.

This commit is contained in:
Ho Ngoc Hai
2026-01-18 02:51:10 +07:00
parent 3cb8b7c6b5
commit 83a8db2942
98 changed files with 4310 additions and 37 deletions

View File

@@ -0,0 +1,25 @@
// EN: Command to cancel an order.
// VI: Command hủy order.
using MediatR;
namespace OrderService.API.Application.Commands;
/// <summary>
/// EN: Command to cancel an order with reason.
/// VI: Command hủy order với lý do.
/// </summary>
public record CancelOrderCommand(
Guid OrderId,
Guid ShopId,
string Reason
) : IRequest<CancelOrderResult>;
/// <summary>
/// EN: Result of cancel order command.
/// VI: Kết quả command hủy order.
/// </summary>
public record CancelOrderResult(
bool Success,
string Status
);

View File

@@ -0,0 +1,65 @@
// EN: Handler for CancelOrderCommand.
// VI: Handler cho CancelOrderCommand.
using MediatR;
using OrderService.Domain.AggregatesModel.OrderAggregate;
using OrderService.Domain.Exceptions;
namespace OrderService.API.Application.Commands;
/// <summary>
/// EN: Handler for cancelling an order.
/// VI: Handler hủy order.
/// </summary>
public class CancelOrderCommandHandler : IRequestHandler<CancelOrderCommand, CancelOrderResult>
{
private readonly IOrderRepository _orderRepository;
private readonly ILogger<CancelOrderCommandHandler> _logger;
public CancelOrderCommandHandler(
IOrderRepository orderRepository,
ILogger<CancelOrderCommandHandler> logger)
{
_orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CancelOrderResult> Handle(
CancelOrderCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"EN: Cancelling order {OrderId} / VI: Hủy order {OrderId}",
request.OrderId);
// EN: Load order
// VI: Load order
var order = await _orderRepository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null)
{
throw new DomainException($"Order not found: {request.OrderId}");
}
// EN: Verify shop ownership
// VI: Xác minh quyền sở hữu shop
if (order.ShopId != request.ShopId)
{
throw new DomainException("Order does not belong to this shop");
}
// EN: Cancel order (domain validates status)
// VI: Hủy order (domain validates status)
order.Cancel(request.Reason);
// EN: Save changes (domain events will be dispatched)
// VI: Lưu thay đổi (domain events sẽ được dispatch)
await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"EN: Order cancelled successfully / VI: Hủy order thành công: {OrderId}, Reason: {Reason}",
order.Id,
request.Reason);
return new CancelOrderResult(true, order.Status.Name);
}
}

View File

@@ -0,0 +1,24 @@
// EN: Command to complete an order.
// VI: Command hoàn thành order.
using MediatR;
namespace OrderService.API.Application.Commands;
/// <summary>
/// EN: Command to mark order as completed.
/// VI: Command đánh dấu order hoàn thành.
/// </summary>
public record CompleteOrderCommand(
Guid OrderId,
Guid ShopId
) : IRequest<CompleteOrderResult>;
/// <summary>
/// EN: Result of complete order command.
/// VI: Kết quả command hoàn thành order.
/// </summary>
public record CompleteOrderResult(
bool Success,
string Status
);

View File

@@ -0,0 +1,64 @@
// EN: Handler for CompleteOrderCommand.
// VI: Handler cho CompleteOrderCommand.
using MediatR;
using OrderService.Domain.AggregatesModel.OrderAggregate;
using OrderService.Domain.Exceptions;
namespace OrderService.API.Application.Commands;
/// <summary>
/// EN: Handler for completing an order.
/// VI: Handler hoàn thành order.
/// </summary>
public class CompleteOrderCommandHandler : IRequestHandler<CompleteOrderCommand, CompleteOrderResult>
{
private readonly IOrderRepository _orderRepository;
private readonly ILogger<CompleteOrderCommandHandler> _logger;
public CompleteOrderCommandHandler(
IOrderRepository orderRepository,
ILogger<CompleteOrderCommandHandler> logger)
{
_orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CompleteOrderResult> Handle(
CompleteOrderCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"EN: Completing order {OrderId} / VI: Hoàn thành order {OrderId}",
request.OrderId);
// EN: Load order
// VI: Load order
var order = await _orderRepository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null)
{
throw new DomainException($"Order not found: {request.OrderId}");
}
// EN: Verify shop ownership
// VI: Xác minh quyền sở hữu shop
if (order.ShopId != request.ShopId)
{
throw new DomainException("Order does not belong to this shop");
}
// EN: Complete order (domain validates status)
// VI: Hoàn thành order (domain validates status)
order.MarkAsCompleted();
// EN: Save changes (domain events will be dispatched)
// VI: Lưu thay đổi (domain events sẽ được dispatch)
await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"EN: Order completed successfully / VI: Hoàn thành order thành công: {OrderId}",
order.Id);
return new CompleteOrderResult(true, order.Status.Name);
}
}

View File

@@ -0,0 +1,38 @@
// EN: Command to create a new order.
// VI: Command tạo order mới.
using MediatR;
namespace OrderService.API.Application.Commands;
/// <summary>
/// EN: Command to create a new order with validation through strategies.
/// VI: Command tạo order mới với validation qua strategies.
/// </summary>
public record CreateOrderCommand(
Guid ShopId,
Guid? CustomerId,
List<OrderItemRequest> Items
) : IRequest<CreateOrderResult>;
/// <summary>
/// EN: Order item request.
/// VI: Yêu cầu order item.
/// </summary>
public record OrderItemRequest(
Guid ProductId,
string ProductName,
string ProductType,
int Quantity,
decimal UnitPrice
);
/// <summary>
/// EN: Result of create order command.
/// VI: Kết quả command tạo order.
/// </summary>
public record CreateOrderResult(
Guid OrderId,
decimal TotalAmount,
string Status
);

View File

@@ -0,0 +1,99 @@
// EN: Handler for CreateOrderCommand.
// VI: Handler cho CreateOrderCommand.
using MediatR;
using OrderService.Domain.AggregatesModel.OrderAggregate;
using OrderService.Domain.Strategies;
namespace OrderService.API.Application.Commands;
/// <summary>
/// EN: Handler for creating a new order with strategy-based validation.
/// VI: Handler tạo order mới với validation qua strategy.
/// </summary>
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, CreateOrderResult>
{
private readonly IOrderRepository _orderRepository;
private readonly IEnumerable<ILineItemStrategy> _strategies;
private readonly ILogger<CreateOrderCommandHandler> _logger;
public CreateOrderCommandHandler(
IOrderRepository orderRepository,
IEnumerable<ILineItemStrategy> strategies,
ILogger<CreateOrderCommandHandler> logger)
{
_orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
_strategies = strategies ?? throw new ArgumentNullException(nameof(strategies));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CreateOrderResult> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"EN: Creating order for shop {ShopId} / VI: Tạo order cho shop {ShopId}",
request.ShopId);
// EN: Create order aggregate
// VI: Tạo order aggregate
var order = new Order(request.ShopId, request.CustomerId);
// EN: Add items to order
// VI: Thêm items vào order
foreach (var itemRequest in request.Items)
{
var orderItem = new OrderItem(
itemRequest.ProductId,
itemRequest.ProductName,
itemRequest.ProductType,
itemRequest.Quantity,
itemRequest.UnitPrice);
order.AddItem(orderItem);
}
// EN: Validate all items through their strategies
// VI: Validate tất cả items qua strategies của chúng
foreach (var item in order.Items)
{
var strategy = GetStrategy(item.ProductType);
var isValid = await strategy.ValidateAsync(item, request.ShopId, cancellationToken);
if (!isValid)
{
throw new InvalidOperationException(
$"Validation failed for item {item.ProductName} (type: {item.ProductType})");
}
}
// EN: Mark order as validated after all items pass validation
// VI: Đánh dấu order là validated sau khi tất cả items pass validation
order.MarkAsValidated();
// EN: Save order
// VI: Lưu order
_orderRepository.Add(order);
await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"EN: Order created successfully / VI: Tạo order thành công: {OrderId}",
order.Id);
return new CreateOrderResult(
order.Id,
order.TotalAmount,
order.Status.Name);
}
private ILineItemStrategy GetStrategy(string productType)
{
var strategy = _strategies.FirstOrDefault(s => s.SupportedType == productType);
if (strategy == null)
{
throw new InvalidOperationException(
$"EN: No strategy found for product type / VI: Không tìm thấy strategy cho loại sản phẩm: {productType}");
}
return strategy;
}
}

View File

@@ -0,0 +1,24 @@
// EN: Command to pay for an order.
// VI: Command thanh toán order.
using MediatR;
namespace OrderService.API.Application.Commands;
/// <summary>
/// EN: Command to process payment and execute order.
/// VI: Command xử lý thanh toán và thực thi order.
/// </summary>
public record PayOrderCommand(
Guid OrderId,
Guid ShopId
) : IRequest<PayOrderResult>;
/// <summary>
/// EN: Result of pay order command.
/// VI: Kết quả command thanh toán order.
/// </summary>
public record PayOrderResult(
bool Success,
string Status
);

View File

@@ -0,0 +1,96 @@
// EN: Handler for PayOrderCommand.
// VI: Handler cho PayOrderCommand.
using MediatR;
using OrderService.Domain.AggregatesModel.OrderAggregate;
using OrderService.Domain.Exceptions;
using OrderService.Domain.Strategies;
namespace OrderService.API.Application.Commands;
/// <summary>
/// EN: Handler for processing payment and executing order items via strategies.
/// VI: Handler xử lý thanh toán và thực thi order items qua strategies.
/// </summary>
public class PayOrderCommandHandler : IRequestHandler<PayOrderCommand, PayOrderResult>
{
private readonly IOrderRepository _orderRepository;
private readonly IEnumerable<ILineItemStrategy> _strategies;
private readonly ILogger<PayOrderCommandHandler> _logger;
public PayOrderCommandHandler(
IOrderRepository orderRepository,
IEnumerable<ILineItemStrategy> strategies,
ILogger<PayOrderCommandHandler> logger)
{
_orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
_strategies = strategies ?? throw new ArgumentNullException(nameof(strategies));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PayOrderResult> Handle(
PayOrderCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"EN: Processing payment for order {OrderId} / VI: Xử lý thanh toán cho order {OrderId}",
request.OrderId);
// EN: Load order
// VI: Load order
var order = await _orderRepository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null)
{
throw new DomainException($"Order not found: {request.OrderId}");
}
// EN: Verify shop ownership
// VI: Xác minh quyền sở hữu shop
if (order.ShopId != request.ShopId)
{
throw new DomainException("Order does not belong to this shop");
}
// EN: Mark order as paid (will validate status internally)
// VI: Đánh dấu order là đã thanh toán (sẽ validate status bên trong)
order.MarkAsPaid();
// EN: Execute all items via their strategies
// VI: Thực thi tất cả items qua strategies của chúng
foreach (var item in order.Items)
{
var strategy = GetStrategy(item.ProductType);
await strategy.ExecuteAsync(item, request.ShopId, cancellationToken);
_logger.LogInformation(
"EN: Executed item {ProductName} via {StrategyType} / VI: Đã thực thi item {ProductName} qua {StrategyType}",
item.ProductName,
strategy.GetType().Name);
}
// EN: Mark order as processing
// VI: Đánh dấu order là đang xử lý
order.MarkAsProcessing();
// EN: Save changes
// VI: Lưu thay đổi
await _orderRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"EN: Payment processed successfully / VI: Xử lý thanh toán thành công: {OrderId}",
order.Id);
return new PayOrderResult(true, order.Status.Name);
}
private ILineItemStrategy GetStrategy(string productType)
{
var strategy = _strategies.FirstOrDefault(s => s.SupportedType == productType);
if (strategy == null)
{
throw new InvalidOperationException(
$"EN: No strategy found for product type / VI: Không tìm thấy strategy cho loại sản phẩm: {productType}");
}
return strategy;
}
}

View File

@@ -0,0 +1,78 @@
// EN: DTOs for Order Service.
// VI: DTOs cho Order Service.
namespace OrderService.API.Application.DTOs;
/// <summary>
/// EN: Complete order details with items.
/// VI: Chi tiết order đầy đủ với items.
/// </summary>
public record OrderDto(
Guid Id,
Guid ShopId,
Guid? CustomerId,
string Status,
decimal TotalAmount,
DateTime CreatedAt,
DateTime? UpdatedAt,
List<OrderItemDto> Items
);
/// <summary>
/// EN: Order item details.
/// VI: Chi tiết order item.
/// </summary>
public record OrderItemDto(
Guid Id,
Guid ProductId,
string ProductName,
string ProductType,
int Quantity,
decimal UnitPrice,
decimal TotalPrice,
string Status
);
/// <summary>
/// EN: Order summary for list views.
/// VI: Tóm tắt order cho danh sách.
/// </summary>
public record OrderSummaryDto(
Guid Id,
Guid ShopId,
Guid? CustomerId,
string Status,
decimal TotalAmount,
int ItemCount,
DateTime CreatedAt
);
/// <summary>
/// EN: Paged result wrapper.
/// VI: Wrapper kết quả phân trang.
/// </summary>
public record PagedResult<T>(
List<T> Items,
int TotalCount,
int PageSize,
int CurrentPage
)
{
/// <summary>
/// EN: Total number of pages.
/// VI: Tổng số trang.
/// </summary>
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
/// <summary>
/// EN: Whether there are more pages.
/// VI: Có thêm trang hay không.
/// </summary>
public bool HasNextPage => CurrentPage < TotalPages;
/// <summary>
/// EN: Whether there are previous pages.
/// VI: Có trang trước hay không.
/// </summary>
public bool HasPreviousPage => CurrentPage > 1;
}

View File

@@ -0,0 +1,16 @@
// EN: Query to get order by ID.
// VI: Query lấy order theo ID.
using MediatR;
using OrderService.API.Application.DTOs;
namespace OrderService.API.Application.Queries;
/// <summary>
/// EN: Query to get order details by ID.
/// VI: Query lấy chi tiết order theo ID.
/// </summary>
public record GetOrderByIdQuery(
Guid OrderId,
Guid ShopId
) : IRequest<OrderDto?>;

View File

@@ -0,0 +1,102 @@
// EN: Handler for GetOrderByIdQuery using Dapper.
// VI: Handler cho GetOrderByIdQuery dùng Dapper.
using System.Data;
using Dapper;
using MediatR;
using OrderService.API.Application.DTOs;
namespace OrderService.API.Application.Queries;
/// <summary>
/// EN: Handler for getting order by ID using optimized Dapper query.
/// VI: Handler lấy order theo ID dùng Dapper query tối ưu.
/// </summary>
public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
private readonly IDbConnection _connection;
private readonly ILogger<GetOrderByIdQueryHandler> _logger;
public GetOrderByIdQueryHandler(
IDbConnection connection,
ILogger<GetOrderByIdQueryHandler> logger)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<OrderDto?> Handle(
GetOrderByIdQuery request,
CancellationToken cancellationToken)
{
const string orderSql = @"
SELECT
o.id AS Id,
o.shop_id AS ShopId,
o.customer_id AS CustomerId,
os.name AS Status,
o.total_amount AS TotalAmount,
o.created_at AS CreatedAt,
o.updated_at AS UpdatedAt
FROM orders o
INNER JOIN order_statuses os ON o.status_id = os.id
WHERE o.id = @OrderId AND o.shop_id = @ShopId";
const string itemsSql = @"
SELECT
oi.id AS Id,
oi.product_id AS ProductId,
oi.product_name AS ProductName,
oi.product_type AS ProductType,
oi.quantity AS Quantity,
oi.unit_price AS UnitPrice,
(oi.quantity * oi.unit_price) AS TotalPrice,
oi.status AS Status
FROM order_items oi
WHERE oi.order_id = @OrderId
ORDER BY oi.id";
// EN: Query order header
// VI: Query order header
var order = await _connection.QueryFirstOrDefaultAsync<OrderDtoRecord>(
orderSql,
new { request.OrderId, request.ShopId });
if (order == null)
{
_logger.LogWarning(
"EN: Order not found / VI: Không tìm thấy order: {OrderId}",
request.OrderId);
return null;
}
// EN: Query order items
// VI: Query order items
var items = await _connection.QueryAsync<OrderItemDto>(
itemsSql,
new { request.OrderId });
return new OrderDto(
order.Id,
order.ShopId,
order.CustomerId,
order.Status,
order.TotalAmount,
order.CreatedAt,
order.UpdatedAt,
items.ToList()
);
}
// EN: Helper record for Dapper mapping
// VI: Record helper cho Dapper mapping
private record OrderDtoRecord(
Guid Id,
Guid ShopId,
Guid? CustomerId,
string Status,
decimal TotalAmount,
DateTime CreatedAt,
DateTime? UpdatedAt
);
}

View File

@@ -0,0 +1,17 @@
// EN: Query to get orders by customer.
// VI: Query lấy orders theo khách hàng.
using MediatR;
using OrderService.API.Application.DTOs;
namespace OrderService.API.Application.Queries;
/// <summary>
/// EN: Query to get orders for a customer with pagination.
/// VI: Query lấy orders cho khách hàng có phân trang.
/// </summary>
public record GetOrdersByCustomerQuery(
Guid CustomerId,
int Page = 1,
int PageSize = 20
) : IRequest<PagedResult<OrderSummaryDto>>;

View File

@@ -0,0 +1,70 @@
// EN: Handler for GetOrdersByCustomerQuery using Dapper.
// VI: Handler cho GetOrdersByCustomerQuery dùng Dapper.
using System.Data;
using Dapper;
using MediatR;
using OrderService.API.Application.DTOs;
namespace OrderService.API.Application.Queries;
/// <summary>
/// EN: Handler for getting customer orders using Dapper.
/// VI: Handler lấy orders của khách hàng dùng Dapper.
/// </summary>
public class GetOrdersByCustomerQueryHandler : IRequestHandler<GetOrdersByCustomerQuery, PagedResult<OrderSummaryDto>>
{
private readonly IDbConnection _connection;
private readonly ILogger<GetOrdersByCustomerQueryHandler> _logger;
public GetOrdersByCustomerQueryHandler(
IDbConnection connection,
ILogger<GetOrdersByCustomerQueryHandler> logger)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PagedResult<OrderSummaryDto>> Handle(
GetOrdersByCustomerQuery request,
CancellationToken cancellationToken)
{
// EN: Get total count
// VI: Lấy tổng số
const string countSql = "SELECT COUNT(*) FROM orders WHERE customer_id = @CustomerId";
var totalCount = await _connection.ExecuteScalarAsync<int>(countSql, new { request.CustomerId });
// EN: Get paged data
// VI: Lấy dữ liệu phân trang
const string sql = @"
SELECT
o.id AS Id,
o.shop_id AS ShopId,
o.customer_id AS CustomerId,
os.name AS Status,
o.total_amount AS TotalAmount,
o.created_at AS CreatedAt,
(SELECT COUNT(*) FROM order_items WHERE order_id = o.id) AS ItemCount
FROM orders o
INNER JOIN order_statuses os ON o.status_id = os.id
WHERE o.customer_id = @CustomerId
ORDER BY o.created_at DESC
LIMIT @PageSize OFFSET @Offset";
var parameters = new
{
request.CustomerId,
request.PageSize,
Offset = (request.Page - 1) * request.PageSize
};
var orders = await _connection.QueryAsync<OrderSummaryDto>(sql, parameters);
return new PagedResult<OrderSummaryDto>(
orders.ToList(),
totalCount,
request.PageSize,
request.Page
);
}
}

View File

@@ -0,0 +1,20 @@
// EN: Query to list orders by shop.
// VI: Query liệt kê orders theo shop.
using MediatR;
using OrderService.API.Application.DTOs;
namespace OrderService.API.Application.Queries;
/// <summary>
/// EN: Query to list orders by shop with filtering and pagination.
/// VI: Query liệt kê orders theo shop có lọc và phân trang.
/// </summary>
public record ListOrdersByShopQuery(
Guid ShopId,
string? Status,
DateTime? FromDate,
DateTime? ToDate,
int Page = 1,
int PageSize = 20
) : IRequest<PagedResult<OrderSummaryDto>>;

View File

@@ -0,0 +1,100 @@
// EN: Handler for ListOrdersByShopQuery using Dapper.
// VI: Handler cho ListOrdersByShopQuery dùng Dapper.
using System.Data;
using System.Text;
using Dapper;
using MediatR;
using OrderService.API.Application.DTOs;
namespace OrderService.API.Application.Queries;
/// <summary>
/// EN: Handler for listing orders by shop with filtering using Dapper.
/// VI: Handler liệt kê orders theo shop với lọc dùng Dapper.
/// </summary>
public class ListOrdersByShopQueryHandler : IRequestHandler<ListOrdersByShopQuery, PagedResult<OrderSummaryDto>>
{
private readonly IDbConnection _connection;
private readonly ILogger<ListOrdersByShopQueryHandler> _logger;
public ListOrdersByShopQueryHandler(
IDbConnection connection,
ILogger<ListOrdersByShopQueryHandler> logger)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PagedResult<OrderSummaryDto>> Handle(
ListOrdersByShopQuery request,
CancellationToken cancellationToken)
{
var (whereClause, parameters) = BuildWhereClause(request);
// EN: Get total count
// VI: Lấy tổng số
var countSql = $"SELECT COUNT(*) FROM orders o INNER JOIN order_statuses os ON o.status_id = os.id {whereClause}";
var totalCount = await _connection.ExecuteScalarAsync<int>(countSql, parameters);
// EN: Get paged data
// VI: Lấy dữ liệu phân trang
var sql = $@"
SELECT
o.id AS Id,
o.shop_id AS ShopId,
o.customer_id AS CustomerId,
os.name AS Status,
o.total_amount AS TotalAmount,
o.created_at AS CreatedAt,
(SELECT COUNT(*) FROM order_items WHERE order_id = o.id) AS ItemCount
FROM orders o
INNER JOIN order_statuses os ON o.status_id = os.id
{whereClause}
ORDER BY o.created_at DESC
LIMIT @PageSize OFFSET @Offset";
parameters.Add("PageSize", request.PageSize);
parameters.Add("Offset", (request.Page - 1) * request.PageSize);
var orders = await _connection.QueryAsync<OrderSummaryDto>(sql, parameters);
return new PagedResult<OrderSummaryDto>(
orders.ToList(),
totalCount,
request.PageSize,
request.Page
);
}
private static (string whereClause, DynamicParameters parameters) BuildWhereClause(ListOrdersByShopQuery request)
{
var conditions = new List<string> { "o.shop_id = @ShopId" };
var parameters = new DynamicParameters();
parameters.Add("ShopId", request.ShopId);
if (!string.IsNullOrEmpty(request.Status))
{
conditions.Add("os.name = @Status");
parameters.Add("Status", request.Status);
}
if (request.FromDate.HasValue)
{
conditions.Add("o.created_at >= @FromDate");
parameters.Add("FromDate", request.FromDate.Value);
}
if (request.ToDate.HasValue)
{
conditions.Add("o.created_at <= @ToDate");
parameters.Add("ToDate", request.ToDate.Value);
}
var whereClause = conditions.Count > 0
? "WHERE " + string.Join(" AND ", conditions)
: string.Empty;
return (whereClause, parameters);
}
}

View File

@@ -0,0 +1,73 @@
// EN: Strategy for PreparedFood products (F&B).
// VI: Strategy cho sản phẩm PreparedFood (F&B).
using OrderService.Domain.AggregatesModel.OrderAggregate;
using OrderService.Domain.Strategies;
using OrderService.API.Infrastructure.HttpClients;
namespace OrderService.API.Application.Strategies;
/// <summary>
/// EN: Strategy for PreparedFood products - routes to F&amp;B Engine.
/// VI: Strategy cho sản phẩm PreparedFood - route đến F&amp;B Engine.
/// </summary>
public class FnbStrategy : ILineItemStrategy
{
private readonly FnbEngineClient _fnbClient;
private readonly ILogger<FnbStrategy> _logger;
public string SupportedType => "PreparedFood";
public FnbStrategy(
FnbEngineClient fnbClient,
ILogger<FnbStrategy> logger)
{
_fnbClient = fnbClient ?? throw new ArgumentNullException(nameof(fnbClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<bool> ValidateAsync(
OrderItem item,
Guid shopId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"EN: Validating PreparedFood product / VI: Validate sản phẩm PreparedFood: {ProductName}",
item.ProductName);
// EN: F&B items are always valid (no inventory check)
// VI: F&B items luôn hợp lệ (không cần kiểm tra kho)
return Task.FromResult(true);
}
public async Task ExecuteAsync(
OrderItem item,
Guid shopId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"EN: Executing PreparedFood product / VI: Thực thi sản phẩm PreparedFood: {ProductName}",
item.ProductName);
// EN: Create kitchen ticket
// VI: Tạo phiếu bếp
var ticketId = await _fnbClient.CreateKitchenTicketAsync(
item.ProductId,
item.ProductName,
shopId,
item.Quantity,
null, // Notes can be added later
cancellationToken);
if (ticketId == null)
{
throw new InvalidOperationException(
$"EN: Failed to create kitchen ticket / VI: Tạo phiếu bếp thất bại: {item.ProductName}");
}
_logger.LogInformation(
"EN: Kitchen ticket created / VI: Phiếu bếp đã tạo: {ProductName}, TicketId: {TicketId}",
item.ProductName,
ticketId);
}
}

View File

@@ -0,0 +1,84 @@
// EN: Strategy for Physical products (Retail).
// VI: Strategy cho sản phẩm Physical (Bán lẻ).
using OrderService.Domain.AggregatesModel.OrderAggregate;
using OrderService.Domain.Strategies;
using OrderService.API.Infrastructure.HttpClients;
namespace OrderService.API.Application.Strategies;
/// <summary>
/// EN: Strategy for Physical products - routes to Inventory Service.
/// VI: Strategy cho sản phẩm Physical - route đến Inventory Service.
/// </summary>
public class RetailStrategy : ILineItemStrategy
{
private readonly InventoryServiceClient _inventoryClient;
private readonly ILogger<RetailStrategy> _logger;
public string SupportedType => "Physical";
public RetailStrategy(
InventoryServiceClient inventoryClient,
ILogger<RetailStrategy> logger)
{
_inventoryClient = inventoryClient ?? throw new ArgumentNullException(nameof(inventoryClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> ValidateAsync(
OrderItem item,
Guid shopId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"EN: Validating Physical product / VI: Validate sản phẩm Physical: {ProductName}",
item.ProductName);
// EN: Check inventory availability
// VI: Kiểm tra hàng tồn kho
var isAvailable = await _inventoryClient.CheckStockAsync(
item.ProductId,
shopId,
item.Quantity,
cancellationToken);
if (!isAvailable)
{
_logger.LogWarning(
"EN: Insufficient stock / VI: Không đủ hàng: {ProductName}, Quantity: {Quantity}",
item.ProductName,
item.Quantity);
}
return isAvailable;
}
public async Task ExecuteAsync(
OrderItem item,
Guid shopId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"EN: Executing Physical product / VI: Thực thi sản phẩm Physical: {ProductName}",
item.ProductName);
// EN: Deduct inventory
// VI: Trừ hàng tồn kho
var success = await _inventoryClient.DeductStockAsync(
item.ProductId,
shopId,
item.Quantity,
cancellationToken);
if (!success)
{
throw new InvalidOperationException(
$"EN: Failed to deduct stock / VI: Trừ kho thất bại: {item.ProductName}");
}
_logger.LogInformation(
"EN: Stock deducted successfully / VI: Trừ kho thành công: {ProductName}",
item.ProductName);
}
}

View File

@@ -0,0 +1,132 @@
// EN: Strategy for Service products.
// VI: Strategy cho sản phẩm Service.
using OrderService.Domain.AggregatesModel.OrderAggregate;
using OrderService.Domain.Strategies;
using OrderService.API.Infrastructure.HttpClients;
using System.Text.Json;
namespace OrderService.API.Application.Strategies;
/// <summary>
/// EN: Strategy for Service products - routes to Booking Service.
/// VI: Strategy cho sản phẩm Service - route đến Booking Service.
/// </summary>
public class ServiceStrategy : ILineItemStrategy
{
private readonly BookingServiceClient _bookingClient;
private readonly ILogger<ServiceStrategy> _logger;
public string SupportedType => "Service";
public ServiceStrategy(
BookingServiceClient bookingClient,
ILogger<ServiceStrategy> logger)
{
_bookingClient = bookingClient ?? throw new ArgumentNullException(nameof(bookingClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> ValidateAsync(
OrderItem item,
Guid shopId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"EN: Validating Service product / VI: Validate sản phẩm Service: {ProductName}",
item.ProductName);
// EN: Parse metadata for appointment details
// VI: Parse metadata cho chi tiết đặt lịch
var metadata = ParseMetadata(item.Metadata);
if (metadata == null)
{
_logger.LogWarning("EN: Invalid metadata for Service item / VI: Metadata không hợp lệ cho Service item");
return false;
}
// EN: Check availability
// VI: Kiểm tra lịch trống
var isAvailable = await _bookingClient.CheckAvailabilityAsync(
item.ProductId,
shopId,
metadata.StartTime,
metadata.DurationMinutes,
cancellationToken);
if (!isAvailable)
{
_logger.LogWarning(
"EN: Time slot not available / VI: Lịch không trống: {ProductName}, Time: {StartTime}",
item.ProductName,
metadata.StartTime);
}
return isAvailable;
}
public async Task ExecuteAsync(
OrderItem item,
Guid shopId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"EN: Executing Service product / VI: Thực thi sản phẩm Service: {ProductName}",
item.ProductName);
// EN: Parse metadata
// VI: Parse metadata
var metadata = ParseMetadata(item.Metadata);
if (metadata == null)
{
throw new InvalidOperationException("EN: Invalid metadata / VI: Metadata không hợp lệ");
}
// EN: Create appointment
// VI: Tạo đặt lịch
var appointmentId = await _bookingClient.CreateAppointmentAsync(
item.ProductId,
shopId,
null, // Customer ID can be null for walk-in
metadata.StartTime,
metadata.DurationMinutes,
cancellationToken);
if (appointmentId == null)
{
throw new InvalidOperationException(
$"EN: Failed to create appointment / VI: Tạo đặt lịch thất bại: {item.ProductName}");
}
_logger.LogInformation(
"EN: Appointment created / VI: Đặt lịch thành công: {ProductName}, AppointmentId: {AppointmentId}",
item.ProductName,
appointmentId);
}
private ServiceMetadata? ParseMetadata(string? metadataJson)
{
if (string.IsNullOrEmpty(metadataJson))
{
return null;
}
try
{
return JsonSerializer.Deserialize<ServiceMetadata>(metadataJson);
}
catch
{
return null;
}
}
}
/// <summary>
/// EN: Metadata for Service items.
/// VI: Metadata cho Service items.
/// </summary>
public record ServiceMetadata(
DateTime StartTime,
int DurationMinutes
);

View File

@@ -0,0 +1,56 @@
// EN: Factory for resolving line item strategies.
// VI: Factory để resolve line item strategies.
using OrderService.Domain.Strategies;
namespace OrderService.API.Application.Strategies;
/// <summary>
/// EN: Factory for getting the appropriate strategy based on product type.
/// VI: Factory lấy strategy phù hợp dựa trên loại sản phẩm.
/// </summary>
public class StrategyFactory
{
private readonly IEnumerable<ILineItemStrategy> _strategies;
private readonly ILogger<StrategyFactory> _logger;
public StrategyFactory(
IEnumerable<ILineItemStrategy> strategies,
ILogger<StrategyFactory> logger)
{
_strategies = strategies ?? throw new ArgumentNullException(nameof(strategies));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get strategy by product type.
/// VI: Lấy strategy theo loại sản phẩm.
/// </summary>
public ILineItemStrategy GetStrategy(string productType)
{
_logger.LogDebug(
"EN: Getting strategy for product type / VI: Lấy strategy cho loại sản phẩm: {ProductType}",
productType);
var strategy = _strategies.FirstOrDefault(s => s.SupportedType.Equals(productType, StringComparison.OrdinalIgnoreCase));
if (strategy == null)
{
var availableTypes = string.Join(", ", _strategies.Select(s => s.SupportedType));
throw new InvalidOperationException(
$"EN: No strategy found for product type: {productType}. Available types: {availableTypes} / " +
$"VI: Không tìm thấy strategy cho loại sản phẩm: {productType}. Các loại có sẵn: {availableTypes}");
}
return strategy;
}
/// <summary>
/// EN: Get all available product types.
/// VI: Lấy tất cả loại sản phẩm có sẵn.
/// </summary>
public IEnumerable<string> GetAvailableTypes()
{
return _strategies.Select(s => s.SupportedType);
}
}

View File

@@ -0,0 +1,30 @@
// EN: Validator for CancelOrderCommand.
// VI: Validator cho CancelOrderCommand.
using FluentValidation;
namespace OrderService.API.Application.Validations;
/// <summary>
/// EN: Validator for CancelOrderCommand.
/// VI: Validator cho CancelOrderCommand.
/// </summary>
public class CancelOrderCommandValidator : AbstractValidator<Commands.CancelOrderCommand>
{
public CancelOrderCommandValidator()
{
RuleFor(x => x.OrderId)
.NotEmpty()
.WithMessage("EN: Order ID is required / VI: Order ID là bắt buộc");
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("EN: Shop ID is required / VI: Shop ID là bắt buộc");
RuleFor(x => x.Reason)
.NotEmpty()
.WithMessage("EN: Cancellation reason is required / VI: Lý do hủy là bắt buộc")
.MaximumLength(500)
.WithMessage("EN: Reason must not exceed 500 characters / VI: Lý do không được vượt quá 500 ký tự");
}
}

View File

@@ -0,0 +1,62 @@
// EN: Validator for CreateOrderCommand.
// VI: Validator cho CreateOrderCommand.
using FluentValidation;
namespace OrderService.API.Application.Validations;
/// <summary>
/// EN: Validator for CreateOrderCommand.
/// VI: Validator cho CreateOrderCommand.
/// </summary>
public class CreateOrderCommandValidator : AbstractValidator<Commands.CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("EN: Shop ID is required / VI: Shop ID là bắt buộc");
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("EN: Order must have at least one item / VI: Order phải có ít nhất một item")
.Must(items => items != null && items.Count > 0)
.WithMessage("EN: Order must have at least one item / VI: Order phải có ít nhất một item");
RuleForEach(x => x.Items).SetValidator(new OrderItemRequestValidator());
}
}
/// <summary>
/// EN: Validator for OrderItemRequest.
/// VI: Validator cho OrderItemRequest.
/// </summary>
public class OrderItemRequestValidator : AbstractValidator<Commands.OrderItemRequest>
{
public OrderItemRequestValidator()
{
RuleFor(x => x.ProductId)
.NotEmpty()
.WithMessage("EN: Product ID is required / VI: Product ID là bắt buộc");
RuleFor(x => x.ProductName)
.NotEmpty()
.WithMessage("EN: Product name is required / VI: Tên sản phẩm là bắt buộc")
.MaximumLength(255)
.WithMessage("EN: Product name must not exceed 255 characters / VI: Tên sản phẩm không được vượt quá 255 ký tự");
RuleFor(x => x.ProductType)
.NotEmpty()
.WithMessage("EN: Product type is required / VI: Loại sản phẩm là bắt buộc")
.Must(type => new[] { "Physical", "Service", "PreparedFood" }.Contains(type))
.WithMessage("EN: Invalid product type / VI: Loại sản phẩm không hợp lệ");
RuleFor(x => x.Quantity)
.GreaterThan(0)
.WithMessage("EN: Quantity must be greater than 0 / VI: Số lượng phải lớn hơn 0");
RuleFor(x => x.UnitPrice)
.GreaterThanOrEqualTo(0)
.WithMessage("EN: Unit price must be greater than or equal to 0 / VI: Đơn giá phải lớn hơn hoặc bằng 0");
}
}

View File

@@ -0,0 +1,24 @@
// EN: Validator for PayOrderCommand.
// VI: Validator cho PayOrderCommand.
using FluentValidation;
namespace OrderService.API.Application.Validations;
/// <summary>
/// EN: Validator for PayOrderCommand.
/// VI: Validator cho PayOrderCommand.
/// </summary>
public class PayOrderCommandValidator : AbstractValidator<Commands.PayOrderCommand>
{
public PayOrderCommandValidator()
{
RuleFor(x => x.OrderId)
.NotEmpty()
.WithMessage("EN: Order ID is required / VI: Order ID là bắt buộc");
RuleFor(x => x.ShopId)
.NotEmpty()
.WithMessage("EN: Shop ID is required / VI: Shop ID là bắt buộc");
}
}

View File

@@ -0,0 +1,144 @@
// EN: Admin Orders REST API Controller.
// VI: Controller REST API cho Admin Orders.
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using OrderService.API.Application.DTOs;
using OrderService.API.Application.Queries;
namespace OrderService.API.Controllers;
/// <summary>
/// EN: Admin Orders API Controller for administrative order management.
/// VI: Controller API Admin Orders cho quản lý đơn hàng dạng admin.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/admin/orders")]
public class AdminOrdersController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<AdminOrdersController> _logger;
public AdminOrdersController(
IMediator mediator,
ILogger<AdminOrdersController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: List all orders with advanced filtering (Admin only).
/// VI: Liệt kê tất cả orders với lọc nâng cao (chỉ Admin).
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<OrderSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<OrderSummaryDto>>> ListAllOrders(
[FromQuery] Guid? shopId = null,
[FromQuery] Guid? customerId = null,
[FromQuery] string? status = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] decimal? minAmount = null,
[FromQuery] decimal? maxAmount = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
// EN: For now, if shopId is provided, use ListOrdersByShopQuery
// VI: Tạm thời, nếu shopId được cung cấp, dùng ListOrdersByShopQuery
// TODO: Create AdminListOrdersQuery with more filters
if (shopId.HasValue)
{
var query = new ListOrdersByShopQuery(
shopId.Value,
status,
fromDate,
toDate,
page,
pageSize);
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}
// EN: If customerId is provided
// VI: Nếu customerId được cung cấp
if (customerId.HasValue)
{
var query = new GetOrdersByCustomerQuery(customerId.Value, page, pageSize);
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}
// EN: Return empty for now
// VI: Trả về rỗng tạm thời
return Ok(new PagedResult<OrderSummaryDto>(
new List<OrderSummaryDto>(),
0,
pageSize,
page));
}
/// <summary>
/// EN: Get order statistics (Admin only).
/// VI: Lấy thống kê orders (chỉ Admin).
/// </summary>
[HttpGet("stats")]
[ProducesResponseType(typeof(OrderStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<OrderStatsDto>> GetOrderStats(
[FromQuery] Guid? shopId = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("EN: Getting order stats / VI: Lấy thống kê orders");
// TODO: Implement GetOrderStatsQuery
// EN: Return mock data for now
// VI: Trả về dữ liệu giả tạm thời
var stats = new OrderStatsDto(
TotalOrders: 0,
TotalRevenue: 0,
AverageOrderValue: 0,
OrdersByStatus: new Dictionary<string, int>()
);
return Ok(stats);
}
/// <summary>
/// EN: Export orders to CSV (Admin only).
/// VI: Xuất orders ra CSV (chỉ Admin).
/// </summary>
[HttpGet("export")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
public async Task<IActionResult> ExportOrders(
[FromQuery] Guid? shopId = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("EN: Exporting orders / VI: Xuất orders");
// TODO: Implement export functionality
// EN: Return empty CSV for now
// VI: Trả về CSV rỗng tạm thời
var csv = "OrderId,ShopId,CustomerId,Status,TotalAmount,CreatedAt\n";
var bytes = System.Text.Encoding.UTF8.GetBytes(csv);
return File(bytes, "text/csv", $"orders_{DateTime.UtcNow:yyyyMMdd}.csv");
}
}
/// <summary>
/// EN: Order statistics DTO.
/// VI: DTO thống kê orders.
/// </summary>
public record OrderStatsDto(
int TotalOrders,
decimal TotalRevenue,
decimal AverageOrderValue,
Dictionary<string, int> OrdersByStatus
);

View File

@@ -0,0 +1,199 @@
// EN: Orders REST API Controller.
// VI: Controller REST API cho Orders.
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using OrderService.API.Application.Commands;
using OrderService.API.Application.DTOs;
using OrderService.API.Application.Queries;
namespace OrderService.API.Controllers;
/// <summary>
/// EN: Orders API Controller for order management.
/// VI: Controller API Orders cho quản lý đơn hàng.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<OrdersController> _logger;
public OrdersController(
IMediator mediator,
ILogger<OrdersController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Create a new order.
/// VI: Tạo order mới.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(CreateOrderResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<CreateOrderResult>> CreateOrder(
[FromBody] CreateOrderCommand command,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"EN: Creating order for shop {ShopId} / VI: Tạo order cho shop {ShopId}",
command.ShopId);
var result = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(
nameof(GetOrderById),
new { id = result.OrderId },
result);
}
/// <summary>
/// EN: Get order by ID.
/// VI: Lấy order theo ID.
/// </summary>
[HttpGet("{id}")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<OrderDto>> GetOrderById(
Guid id,
[FromQuery] Guid shopId,
CancellationToken cancellationToken = default)
{
var query = new GetOrderByIdQuery(id, shopId);
var result = await _mediator.Send(query, cancellationToken);
if (result == null)
{
return NotFound(new { Message = $"Order with ID {id} not found" });
}
return Ok(result);
}
/// <summary>
/// EN: List orders by shop with filtering and pagination.
/// VI: Liệt kê orders theo shop có lọc và phân trang.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<OrderSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<OrderSummaryDto>>> ListOrdersByShop(
[FromQuery] Guid shopId,
[FromQuery] string? status = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var query = new ListOrdersByShopQuery(
shopId,
status,
fromDate,
toDate,
page,
pageSize);
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}
/// <summary>
/// EN: Process payment for an order.
/// VI: Xử lý thanh toán cho order.
/// </summary>
[HttpPost("{id}/pay")]
[ProducesResponseType(typeof(PayOrderResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayOrderResult>> PayOrder(
Guid id,
[FromQuery] Guid shopId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"EN: Processing payment for order {OrderId} / VI: Xử lý thanh toán cho order {OrderId}",
id);
var command = new PayOrderCommand(id, shopId);
var result = await _mediator.Send(command, cancellationToken);
return Ok(result);
}
/// <summary>
/// EN: Cancel an order.
/// VI: Hủy order.
/// </summary>
[HttpPost("{id}/cancel")]
[ProducesResponseType(typeof(CancelOrderResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CancelOrderResult>> CancelOrder(
Guid id,
[FromQuery] Guid shopId,
[FromBody] CancelOrderRequest request,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"EN: Cancelling order {OrderId} / VI: Hủy order {OrderId}",
id);
var command = new CancelOrderCommand(id, shopId, request.Reason);
var result = await _mediator.Send(command, cancellationToken);
return Ok(result);
}
/// <summary>
/// EN: Complete an order.
/// VI: Hoàn thành order.
/// </summary>
[HttpPost("{id}/complete")]
[ProducesResponseType(typeof(CompleteOrderResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CompleteOrderResult>> CompleteOrder(
Guid id,
[FromQuery] Guid shopId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"EN: Completing order {OrderId} / VI: Hoàn thành order {OrderId}",
id);
var command = new CompleteOrderCommand(id, shopId);
var result = await _mediator.Send(command, cancellationToken);
return Ok(result);
}
/// <summary>
/// EN: Get orders by customer.
/// VI: Lấy orders theo khách hàng.
/// </summary>
[HttpGet("customer/{customerId}")]
[ProducesResponseType(typeof(PagedResult<OrderSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<OrderSummaryDto>>> GetOrdersByCustomer(
Guid customerId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var query = new GetOrdersByCustomerQuery(customerId, page, pageSize);
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}
}
/// <summary>
/// EN: Request to cancel an order.
/// VI: Yêu cầu hủy order.
/// </summary>
public record CancelOrderRequest(string Reason);

View File

@@ -0,0 +1,112 @@
// EN: HTTP client for Booking Service.
// VI: HTTP client cho Booking Service.
namespace OrderService.API.Infrastructure.HttpClients;
/// <summary>
/// EN: Client for calling Booking Service for appointment operations.
/// VI: Client gọi Booking Service để thực hiện thao tác đặt lịch.
/// </summary>
public class BookingServiceClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<BookingServiceClient> _logger;
public BookingServiceClient(
HttpClient httpClient,
ILogger<BookingServiceClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Check availability for service appointment.
/// VI: Kiểm tra lịch trống cho dịch vụ.
/// </summary>
public async Task<bool> CheckAvailabilityAsync(
Guid serviceId,
Guid shopId,
DateTime startTime,
int durationMinutes,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation(
"EN: Checking availability / VI: Kiểm tra lịch trống: Service={ServiceId}, Time={StartTime}",
serviceId, startTime);
var response = await _httpClient.GetAsync(
$"/api/v1/bookings/check-availability?serviceId={serviceId}&shopId={shopId}&startTime={startTime:O}&duration={durationMinutes}",
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return false;
}
var result = await response.Content.ReadFromJsonAsync<AvailabilityCheckResult>(cancellationToken);
return result?.IsAvailable ?? false;
}
catch (Exception ex)
{
_logger.LogError(ex, "EN: Error checking availability / VI: Lỗi khi kiểm tra lịch");
return false;
}
}
/// <summary>
/// EN: Create appointment reservation.
/// VI: Tạo đặt lịch.
/// </summary>
public async Task<Guid?> CreateAppointmentAsync(
Guid serviceId,
Guid shopId,
Guid? customerId,
DateTime startTime,
int durationMinutes,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation(
"EN: Creating appointment / VI: Tạo đặt lịch: Service={ServiceId}, Time={StartTime}",
serviceId, startTime);
var request = new CreateAppointmentRequest(
serviceId,
shopId,
customerId,
startTime,
durationMinutes);
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/bookings/appointments",
request,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
var result = await response.Content.ReadFromJsonAsync<CreateAppointmentResult>(cancellationToken);
return result?.AppointmentId;
}
catch (Exception ex)
{
_logger.LogError(ex, "EN: Error creating appointment / VI: Lỗi khi tạo đặt lịch");
return null;
}
}
}
public record AvailabilityCheckResult(bool IsAvailable);
public record CreateAppointmentRequest(
Guid ServiceId,
Guid ShopId,
Guid? CustomerId,
DateTime StartTime,
int DurationMinutes);
public record CreateAppointmentResult(Guid AppointmentId);

View File

@@ -0,0 +1,69 @@
// EN: HTTP client for Catalog Service.
// VI: HTTP client cho Catalog Service.
namespace OrderService.API.Infrastructure.HttpClients;
/// <summary>
/// EN: Client for calling Catalog Service to get product details.
/// VI: Client gọi Catalog Service để lấy chi tiết sản phẩm.
/// </summary>
public class CatalogServiceClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<CatalogServiceClient> _logger;
public CatalogServiceClient(
HttpClient httpClient,
ILogger<CatalogServiceClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get product by ID.
/// VI: Lấy sản phẩm theo ID.
/// </summary>
public async Task<ProductDto?> GetProductByIdAsync(Guid productId, CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation(
"EN: Getting product from Catalog Service / VI: Lấy sản phẩm từ Catalog Service: {ProductId}",
productId);
var response = await _httpClient.GetAsync(
$"/api/v1/products/{productId}",
cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"EN: Product not found in Catalog Service / VI: Không tìm thấy sản phẩm trong Catalog Service: {ProductId}",
productId);
return null;
}
return await response.Content.ReadFromJsonAsync<ProductDto>(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex,
"EN: Error calling Catalog Service / VI: Lỗi khi gọi Catalog Service: {ProductId}",
productId);
throw;
}
}
}
/// <summary>
/// EN: Product DTO from Catalog Service.
/// VI: DTO sản phẩm từ Catalog Service.
/// </summary>
public record ProductDto(
Guid Id,
string Name,
string Type,
decimal Price,
bool IsActive
);

View File

@@ -0,0 +1,77 @@
// EN: HTTP client for F&B Engine.
// VI: HTTP client cho F&B Engine.
namespace OrderService.API.Infrastructure.HttpClients;
/// <summary>
/// EN: Client for calling F&amp;B Engine for kitchen operations.
/// VI: Client gọi F&amp;B Engine để thực hiện thao tác bếp.
/// </summary>
public class FnbEngineClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<FnbEngineClient> _logger;
public FnbEngineClient(
HttpClient httpClient,
ILogger<FnbEngineClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Create kitchen ticket for prepared food.
/// VI: Tạo phiếu bếp cho món ăn.
/// </summary>
public async Task<Guid?> CreateKitchenTicketAsync(
Guid productId,
string productName,
Guid shopId,
int quantity,
string? notes,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation(
"EN: Creating kitchen ticket / VI: Tạo phiếu bếp: {ProductName}, Quantity: {Quantity}",
productName, quantity);
var request = new CreateKitchenTicketRequest(
productId,
productName,
shopId,
quantity,
notes);
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/fnb/kitchen-tickets",
request,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("EN: Failed to create kitchen ticket / VI: Tạo phiếu bếp thất bại");
return null;
}
var result = await response.Content.ReadFromJsonAsync<CreateKitchenTicketResult>(cancellationToken);
return result?.TicketId;
}
catch (Exception ex)
{
_logger.LogError(ex, "EN: Error creating kitchen ticket / VI: Lỗi khi tạo phiếu bếp");
return null;
}
}
}
public record CreateKitchenTicketRequest(
Guid ProductId,
string ProductName,
Guid ShopId,
int Quantity,
string? Notes);
public record CreateKitchenTicketResult(Guid TicketId);

View File

@@ -0,0 +1,92 @@
// EN: HTTP client for Inventory Service.
// VI: HTTP client cho Inventory Service.
namespace OrderService.API.Infrastructure.HttpClients;
/// <summary>
/// EN: Client for calling Inventory Service for stock operations.
/// VI: Client gọi Inventory Service để thực hiện thao tác kho.
/// </summary>
public class InventoryServiceClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<InventoryServiceClient> _logger;
public InventoryServiceClient(
HttpClient httpClient,
ILogger<InventoryServiceClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Check if stock is available.
/// VI: Kiểm tra hàng còn trong kho.
/// </summary>
public async Task<bool> CheckStockAsync(
Guid productId,
Guid shopId,
int quantity,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation(
"EN: Checking stock / VI: Kiểm tra kho: Product={ProductId}, Shop={ShopId}, Quantity={Quantity}",
productId, shopId, quantity);
var response = await _httpClient.GetAsync(
$"/api/v1/inventory/check?productId={productId}&shopId={shopId}&quantity={quantity}",
cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("EN: Stock check failed / VI: Kiểm tra kho thất bại");
return false;
}
var result = await response.Content.ReadFromJsonAsync<StockCheckResult>(cancellationToken);
return result?.IsAvailable ?? false;
}
catch (Exception ex)
{
_logger.LogError(ex, "EN: Error checking stock / VI: Lỗi khi kiểm tra kho");
return false;
}
}
/// <summary>
/// EN: Deduct stock from inventory.
/// VI: Trừ hàng khỏi kho.
/// </summary>
public async Task<bool> DeductStockAsync(
Guid productId,
Guid shopId,
int quantity,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation(
"EN: Deducting stock / VI: Trừ kho: Product={ProductId}, Shop={ShopId}, Quantity={Quantity}",
productId, shopId, quantity);
var request = new DeductStockRequest(productId, shopId, quantity);
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/inventory/deduct",
request,
cancellationToken);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogError(ex, "EN: Error deducting stock / VI: Lỗi khi trừ kho");
return false;
}
}
}
public record StockCheckResult(bool IsAvailable, int AvailableQuantity);
public record DeductStockRequest(Guid ProductId, Guid ShopId, int Quantity);

View File

@@ -1,8 +1,15 @@
using System.Data;
using Asp.Versioning;
using FluentValidation;
using Hellang.Middleware.ProblemDetails;
using Npgsql;
using OrderService.API.Application.Behaviors;
using OrderService.API.Application.Strategies;
using OrderService.API.Infrastructure.HttpClients;
using OrderService.Domain.Strategies;
using OrderService.Infrastructure;
using Polly;
using Polly.Extensions.Http;
using Serilog;
// EN: Configure Serilog early / VI: Cấu hình Serilog sớm
@@ -26,6 +33,62 @@ try
// EN: Add Infrastructure services / VI: Thêm Infrastructure services
builder.Services.AddInfrastructure(builder.Configuration);
// EN: Add Dapper IDbConnection / VI: Thêm Dapper IDbConnection
builder.Services.AddTransient<IDbConnection>(sp =>
{
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? builder.Configuration["DATABASE_URL"]
?? throw new InvalidOperationException("Database connection string is required");
return new NpgsqlConnection(connectionString);
});
// EN: Add HTTP Clients with Polly / VI: Thêm HTTP Clients với Polly
var retryPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
var circuitBreakerPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
builder.Services.AddHttpClient<CatalogServiceClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Services:CatalogService"] ?? "http://catalog-service-net:8080");
client.Timeout = TimeSpan.FromSeconds(10);
})
.AddPolicyHandler(retryPolicy)
.AddPolicyHandler(circuitBreakerPolicy);
builder.Services.AddHttpClient<InventoryServiceClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Services:InventoryService"] ?? "http://inventory-service-net:8080");
client.Timeout = TimeSpan.FromSeconds(10);
})
.AddPolicyHandler(retryPolicy)
.AddPolicyHandler(circuitBreakerPolicy);
builder.Services.AddHttpClient<BookingServiceClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Services:BookingService"] ?? "http://booking-service-net:8080");
client.Timeout = TimeSpan.FromSeconds(10);
})
.AddPolicyHandler(retryPolicy)
.AddPolicyHandler(circuitBreakerPolicy);
builder.Services.AddHttpClient<FnbEngineClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Services:FnbEngine"] ?? "http://fnb-engine-net:8080");
client.Timeout = TimeSpan.FromSeconds(10);
})
.AddPolicyHandler(retryPolicy)
.AddPolicyHandler(circuitBreakerPolicy);
// EN: Register Strategies / VI: Đăng ký Strategies
builder.Services.AddTransient<ILineItemStrategy, RetailStrategy>();
builder.Services.AddTransient<ILineItemStrategy, ServiceStrategy>();
builder.Services.AddTransient<ILineItemStrategy, FnbStrategy>();
builder.Services.AddSingleton<StrategyFactory>();
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
builder.Services.AddMediatR(cfg =>
{