diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/UpdateShopSettingsCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/UpdateShopSettingsCommand.cs
new file mode 100644
index 00000000..1204d72b
--- /dev/null
+++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/UpdateShopSettingsCommand.cs
@@ -0,0 +1,98 @@
+// EN: Command to update shop settings (features, operating hours).
+// VI: Command để cập nhật cài đặt shop (tính năng, giờ hoạt động).
+
+using MediatR;
+using MerchantService.Domain.AggregatesModel.ShopAggregate;
+using MerchantService.Domain.Exceptions;
+
+namespace MerchantService.API.Application.Commands.Shops;
+
+///
+/// EN: Command to update shop settings.
+/// VI: Command để cập nhật cài đặt shop.
+///
+public record UpdateShopSettingsCommand : IRequest
+{
+ public Guid ShopId { get; init; }
+ public UpdateShopFeaturesRequest? Features { get; init; }
+ public string? OpenTime { get; init; }
+ public string? CloseTime { get; init; }
+ public List? OpenDays { get; init; }
+}
+
+///
+/// EN: Request model for updating shop features.
+/// VI: Model request để cập nhật tính năng shop.
+///
+public record UpdateShopFeaturesRequest
+{
+ public bool HasInventory { get; init; }
+ public bool HasBooking { get; init; }
+ public bool HasTables { get; init; }
+ public bool HasKitchen { get; init; }
+ public bool HasShipping { get; init; }
+ public bool HasDelivery { get; init; }
+}
+
+///
+/// EN: Handler for updating shop settings.
+/// VI: Handler để cập nhật cài đặt shop.
+///
+public class UpdateShopSettingsCommandHandler : IRequestHandler
+{
+ private readonly IShopRepository _shopRepository;
+
+ public UpdateShopSettingsCommandHandler(IShopRepository shopRepository)
+ {
+ _shopRepository = shopRepository;
+ }
+
+ public async Task Handle(UpdateShopSettingsCommand request, CancellationToken cancellationToken)
+ {
+ var shop = await _shopRepository.GetByIdAsync(request.ShopId, cancellationToken)
+ ?? throw new DomainException($"Shop {request.ShopId} not found");
+
+ // EN: Update features if provided
+ // VI: Cập nhật tính năng nếu được cung cấp
+ if (request.Features != null)
+ {
+ var features = new ShopFeatures
+ {
+ HasInventory = request.Features.HasInventory,
+ HasBooking = request.Features.HasBooking,
+ HasTables = request.Features.HasTables,
+ HasKitchen = request.Features.HasKitchen,
+ HasShipping = request.Features.HasShipping,
+ HasDelivery = request.Features.HasDelivery
+ };
+ shop.UpdateFeatures(features);
+ }
+
+ // EN: Update operating hours if time fields are provided
+ // VI: Cập nhật giờ hoạt động nếu các trường thời gian được cung cấp
+ if (request.OpenTime != null || request.CloseTime != null || request.OpenDays != null)
+ {
+ var currentHours = shop.OperatingHours;
+ var openTime = request.OpenTime != null
+ ? TimeOnly.Parse(request.OpenTime)
+ : currentHours?.OpenTime ?? new TimeOnly(8, 0);
+ var closeTime = request.CloseTime != null
+ ? TimeOnly.Parse(request.CloseTime)
+ : currentHours?.CloseTime ?? new TimeOnly(22, 0);
+ var openDays = request.OpenDays != null
+ ? request.OpenDays.Select(d => Enum.Parse(d, ignoreCase: true)).ToList()
+ : currentHours?.OpenDays ?? Enum.GetValues().ToList();
+
+ var hours = new OperatingHours
+ {
+ OpenTime = openTime,
+ CloseTime = closeTime,
+ OpenDays = openDays
+ };
+ shop.UpdateOperatingHours(hours);
+ }
+
+ _shopRepository.Update(shop);
+ await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+ }
+}
diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Devices/DeviceQueries.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Devices/DeviceQueries.cs
new file mode 100644
index 00000000..8bd26cd7
--- /dev/null
+++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Devices/DeviceQueries.cs
@@ -0,0 +1,96 @@
+// EN: Query to get registered POS devices for current merchant.
+// VI: Query để lấy danh sách thiết bị POS đã đăng ký của merchant hiện tại.
+
+using MediatR;
+using MerchantService.Domain.AggregatesModel.MerchantAggregate;
+using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
+using MerchantService.Domain.Exceptions;
+
+namespace MerchantService.API.Application.Queries.Devices;
+
+///
+/// EN: Query to get registered devices for current merchant.
+/// VI: Query để lấy thiết bị đã đăng ký của merchant hiện tại.
+///
+public record GetDevicesQuery : IRequest>;
+
+///
+/// EN: Device DTO.
+/// VI: DTO thiết bị.
+///
+public record DeviceDto
+{
+ public Guid Id { get; init; }
+ public Guid StaffId { get; init; }
+ public string? StaffCode { get; init; }
+ public string DeviceToken { get; init; } = null!;
+ public string? DeviceName { get; init; }
+ public string Platform { get; init; } = null!;
+ public bool IsActive { get; init; }
+ public DateTime? LastUsedAt { get; init; }
+ public DateTime CreatedAt { get; init; }
+}
+
+///
+/// EN: Handler for getting registered devices.
+/// VI: Handler để lấy danh sách thiết bị đã đăng ký.
+///
+public class GetDevicesQueryHandler : IRequestHandler>
+{
+ private readonly IMerchantRepository _merchantRepository;
+ private readonly IMerchantStaffRepository _staffRepository;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ public GetDevicesQueryHandler(
+ IMerchantRepository merchantRepository,
+ IMerchantStaffRepository staffRepository,
+ IHttpContextAccessor httpContextAccessor)
+ {
+ _merchantRepository = merchantRepository;
+ _staffRepository = staffRepository;
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ public async Task> Handle(GetDevicesQuery request, CancellationToken cancellationToken)
+ {
+ var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
+ ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
+
+ if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
+ {
+ throw new DomainException("User not authenticated");
+ }
+
+ var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken);
+ if (merchant == null)
+ {
+ return [];
+ }
+
+ // EN: Get all staff for this merchant, then collect their devices.
+ // VI: Lấy tất cả nhân viên của merchant, sau đó thu thập thiết bị.
+ var staffList = await _staffRepository.GetByMerchantIdAsync(merchant.Id, cancellationToken);
+
+ var devices = new List();
+ foreach (var staff in staffList)
+ {
+ foreach (var device in staff.DeviceTokens)
+ {
+ devices.Add(new DeviceDto
+ {
+ Id = device.Id,
+ StaffId = staff.Id,
+ StaffCode = staff.EmployeeCode,
+ DeviceToken = device.DeviceId,
+ DeviceName = device.DeviceName,
+ Platform = device.Platform,
+ IsActive = device.LastUsedAt.HasValue && device.LastUsedAt.Value > DateTime.UtcNow.AddDays(-30),
+ LastUsedAt = device.LastUsedAt,
+ CreatedAt = device.CreatedAt
+ });
+ }
+ }
+
+ return devices.OrderByDescending(d => d.CreatedAt).ToList();
+ }
+}
diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/ShopSettingsQueries.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/ShopSettingsQueries.cs
new file mode 100644
index 00000000..b582f3c1
--- /dev/null
+++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/ShopSettingsQueries.cs
@@ -0,0 +1,80 @@
+// EN: Query to get shop settings (features, operating hours).
+// VI: Query để lấy cài đặt shop (tính năng, giờ hoạt động).
+
+using MediatR;
+using MerchantService.Domain.AggregatesModel.ShopAggregate;
+
+namespace MerchantService.API.Application.Queries.Shops;
+
+///
+/// EN: Query to get shop settings by shop ID.
+/// VI: Query để lấy cài đặt shop theo shop ID.
+///
+public record GetShopSettingsQuery(Guid ShopId) : IRequest;
+
+///
+/// EN: Shop settings DTO with feature flags and operating hours.
+/// VI: DTO cài đặt shop với feature flags và giờ hoạt động.
+///
+public record ShopSettingsDto
+{
+ public Guid ShopId { get; init; }
+ public ShopFeaturesDto Features { get; init; } = new();
+ public string? OpenTime { get; init; }
+ public string? CloseTime { get; init; }
+ public List OpenDays { get; init; } = [];
+}
+
+///
+/// EN: Shop features DTO.
+/// VI: DTO tính năng shop.
+///
+public record ShopFeaturesDto
+{
+ public bool HasInventory { get; init; }
+ public bool HasBooking { get; init; }
+ public bool HasTables { get; init; }
+ public bool HasKitchen { get; init; }
+ public bool HasShipping { get; init; }
+ public bool HasDelivery { get; init; }
+}
+
+///
+/// EN: Handler for getting shop settings.
+/// VI: Handler để lấy cài đặt shop.
+///
+public class GetShopSettingsQueryHandler : IRequestHandler
+{
+ private readonly IShopRepository _shopRepository;
+
+ public GetShopSettingsQueryHandler(IShopRepository shopRepository)
+ {
+ _shopRepository = shopRepository;
+ }
+
+ public async Task Handle(GetShopSettingsQuery request, CancellationToken cancellationToken)
+ {
+ var shop = await _shopRepository.GetByIdAsync(request.ShopId, cancellationToken);
+ if (shop == null)
+ {
+ return null;
+ }
+
+ return new ShopSettingsDto
+ {
+ ShopId = shop.Id,
+ Features = new ShopFeaturesDto
+ {
+ HasInventory = shop.Features.HasInventory,
+ HasBooking = shop.Features.HasBooking,
+ HasTables = shop.Features.HasTables,
+ HasKitchen = shop.Features.HasKitchen,
+ HasShipping = shop.Features.HasShipping,
+ HasDelivery = shop.Features.HasDelivery
+ },
+ OpenTime = shop.OperatingHours?.OpenTime.ToString("HH:mm"),
+ CloseTime = shop.OperatingHours?.CloseTime.ToString("HH:mm"),
+ OpenDays = shop.OperatingHours?.OpenDays?.Select(d => d.ToString()).ToList() ?? []
+ };
+ }
+}
diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/ShopStatsQueries.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/ShopStatsQueries.cs
new file mode 100644
index 00000000..9cfc9ee4
--- /dev/null
+++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/ShopStatsQueries.cs
@@ -0,0 +1,79 @@
+// EN: Query to get per-shop stats for current merchant.
+// VI: Query để lấy thống kê từng shop của merchant hiện tại.
+
+using MediatR;
+using MerchantService.Domain.AggregatesModel.MerchantAggregate;
+using MerchantService.Domain.AggregatesModel.ShopAggregate;
+using MerchantService.Domain.Exceptions;
+
+namespace MerchantService.API.Application.Queries.Shops;
+
+///
+/// EN: Query to get per-shop stats for current merchant.
+/// VI: Query để lấy thống kê từng shop của merchant hiện tại.
+///
+public record GetShopStatsQuery : IRequest>;
+
+///
+/// EN: Per-shop stats DTO with placeholder values.
+/// VI: DTO thống kê từng shop với giá trị placeholder.
+///
+public record ShopStatsDto
+{
+ public Guid ShopId { get; init; }
+ public int ProductCount { get; init; }
+ public int OrderCount { get; init; }
+ public int StaffCount { get; init; }
+ public decimal Revenue { get; init; }
+}
+
+///
+/// EN: Handler for getting per-shop stats.
+/// VI: Handler để lấy thống kê từng shop.
+///
+public class GetShopStatsQueryHandler : IRequestHandler>
+{
+ private readonly IMerchantRepository _merchantRepository;
+ private readonly IShopRepository _shopRepository;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ public GetShopStatsQueryHandler(
+ IMerchantRepository merchantRepository,
+ IShopRepository shopRepository,
+ IHttpContextAccessor httpContextAccessor)
+ {
+ _merchantRepository = merchantRepository;
+ _shopRepository = shopRepository;
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ public async Task> Handle(GetShopStatsQuery request, CancellationToken cancellationToken)
+ {
+ var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
+ ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
+
+ if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
+ {
+ throw new DomainException("User not authenticated");
+ }
+
+ var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken);
+ if (merchant == null)
+ {
+ return [];
+ }
+
+ var shops = await _shopRepository.GetByMerchantIdAsync(merchant.Id, cancellationToken);
+
+ // EN: Return placeholder stats (0 values) — cross-service aggregation needs event-driven approach.
+ // VI: Trả về stats placeholder (giá trị 0) — aggregation đa dịch vụ cần event-driven.
+ return shops.Select(s => new ShopStatsDto
+ {
+ ShopId = s.Id,
+ ProductCount = 0,
+ OrderCount = 0,
+ StaffCount = 0,
+ Revenue = 0
+ }).ToList();
+ }
+}
diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Staff/StaffRoleQueries.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Staff/StaffRoleQueries.cs
new file mode 100644
index 00000000..2e6ccbe6
--- /dev/null
+++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Staff/StaffRoleQueries.cs
@@ -0,0 +1,47 @@
+// EN: Query to get available staff roles.
+// VI: Query để lấy danh sách vai trò nhân viên.
+
+using MediatR;
+using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
+using MerchantService.Domain.SeedWork;
+
+namespace MerchantService.API.Application.Queries.Staff;
+
+///
+/// EN: Query to get available staff roles.
+/// VI: Query để lấy danh sách vai trò nhân viên có sẵn.
+///
+public record GetStaffRolesQuery : IRequest>;
+
+///
+/// EN: Staff role DTO.
+/// VI: DTO vai trò nhân viên.
+///
+public record StaffRoleDto
+{
+ public int Id { get; init; }
+ public string Name { get; init; } = null!;
+}
+
+///
+/// EN: Handler for getting available staff roles from domain enumeration.
+/// VI: Handler để lấy danh sách vai trò nhân viên từ domain enumeration.
+///
+public class GetStaffRolesQueryHandler : IRequestHandler>
+{
+ public Task> Handle(GetStaffRolesQuery request, CancellationToken cancellationToken)
+ {
+ // EN: Get all staff roles from domain enumeration.
+ // VI: Lấy tất cả vai trò nhân viên từ domain enumeration.
+ var roles = Enumeration.GetAll()
+ .Select(r => new StaffRoleDto
+ {
+ Id = r.Id,
+ Name = r.Name
+ })
+ .OrderBy(r => r.Id)
+ .ToList();
+
+ return Task.FromResult>(roles);
+ }
+}
diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/DevicesController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/DevicesController.cs
new file mode 100644
index 00000000..2d65cbbb
--- /dev/null
+++ b/services/merchant-service-net/src/MerchantService.API/Controllers/DevicesController.cs
@@ -0,0 +1,38 @@
+// EN: Devices Controller for listing registered POS devices.
+// VI: Controller Devices để liệt kê thiết bị POS đã đăng ký.
+
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using MerchantService.API.Application.Queries.Devices;
+
+namespace MerchantService.API.Controllers;
+
+///
+/// EN: Controller for device management.
+/// VI: Controller để quản lý thiết bị.
+///
+[ApiController]
+[Route("api/v1/devices")]
+[Authorize]
+public class DevicesController : ControllerBase
+{
+ private readonly IMediator _mediator;
+
+ public DevicesController(IMediator mediator)
+ {
+ _mediator = mediator;
+ }
+
+ ///
+ /// EN: Get registered devices for current merchant.
+ /// VI: Lấy danh sách thiết bị đã đăng ký của merchant hiện tại.
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task GetDevices()
+ {
+ var result = await _mediator.Send(new GetDevicesQuery());
+ return Ok(result);
+ }
+}
diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/ShopsController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/ShopsController.cs
index b72494af..19570f10 100644
--- a/services/merchant-service-net/src/MerchantService.API/Controllers/ShopsController.cs
+++ b/services/merchant-service-net/src/MerchantService.API/Controllers/ShopsController.cs
@@ -27,6 +27,63 @@ public class ShopsController : ControllerBase
_logger = logger;
}
+ ///
+ /// EN: Get per-shop stats for current merchant.
+ /// VI: Lấy thống kê từng shop của merchant hiện tại.
+ ///
+ [HttpGet("stats")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task GetStats()
+ {
+ var result = await _mediator.Send(new GetShopStatsQuery());
+ return Ok(result);
+ }
+
+ ///
+ /// EN: Get shop settings (features, operating hours).
+ /// VI: Lấy cài đặt shop (tính năng, giờ hoạt động).
+ ///
+ [HttpGet("{shopId:guid}/settings")]
+ [ProducesResponseType(typeof(ShopSettingsDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task GetSettings(Guid shopId)
+ {
+ var result = await _mediator.Send(new GetShopSettingsQuery(shopId));
+ if (result == null)
+ {
+ return NotFound(new { message = "Shop not found" });
+ }
+ return Ok(result);
+ }
+
+ ///
+ /// EN: Update shop settings (features, operating hours).
+ /// VI: Cập nhật cài đặt shop (tính năng, giờ hoạt động).
+ ///
+ [HttpPut("{shopId:guid}/settings")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task UpdateSettings(Guid shopId, [FromBody] UpdateShopSettingsBody body)
+ {
+ try
+ {
+ var command = new UpdateShopSettingsCommand
+ {
+ ShopId = shopId,
+ Features = body.Features,
+ OpenTime = body.OpenTime,
+ CloseTime = body.CloseTime,
+ OpenDays = body.OpenDays
+ };
+ await _mediator.Send(command);
+ return Ok(new { message = "Shop settings updated successfully" });
+ }
+ catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("not found"))
+ {
+ return NotFound(new { message = ex.Message });
+ }
+ }
+
///
/// EN: Get current merchant's shops.
/// VI: Lấy danh sách shops của merchant hiện tại.
@@ -336,3 +393,15 @@ public record UpdateBranchRequest
public string? Phone { get; init; }
}
+///
+/// EN: Request model for updating shop settings.
+/// VI: Model request để cập nhật cài đặt shop.
+///
+public record UpdateShopSettingsBody
+{
+ public UpdateShopFeaturesRequest? Features { get; init; }
+ public string? OpenTime { get; init; }
+ public string? CloseTime { get; init; }
+ public List? OpenDays { get; init; }
+}
+
diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/StaffController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/StaffController.cs
index 16eeb907..c6894892 100644
--- a/services/merchant-service-net/src/MerchantService.API/Controllers/StaffController.cs
+++ b/services/merchant-service-net/src/MerchantService.API/Controllers/StaffController.cs
@@ -141,6 +141,19 @@ public class StaffPublicController : ControllerBase
_logger = logger;
}
+ ///
+ /// EN: Get available staff roles.
+ /// VI: Lấy danh sách vai trò nhân viên có sẵn.
+ ///
+ [HttpGet("roles")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task GetRoles()
+ {
+ var result = await _mediator.Send(new GetStaffRolesQuery());
+ return Ok(result);
+ }
+
///
/// EN: Accept a staff invitation.
/// VI: Chấp nhận lời mời làm nhân viên.
diff --git a/services/order-service-net/src/OrderService.API/Application/Queries/GetPosDashboardQuery.cs b/services/order-service-net/src/OrderService.API/Application/Queries/GetPosDashboardQuery.cs
new file mode 100644
index 00000000..693bd11c
--- /dev/null
+++ b/services/order-service-net/src/OrderService.API/Application/Queries/GetPosDashboardQuery.cs
@@ -0,0 +1,182 @@
+// EN: Query to get POS dashboard statistics for today.
+// VI: Query lấy thống kê dashboard POS cho hôm nay.
+
+using System.Data;
+using Dapper;
+using MediatR;
+
+namespace OrderService.API.Application.Queries;
+
+///
+/// EN: Query for POS dashboard data.
+/// VI: Query cho dữ liệu dashboard POS.
+///
+public record GetPosDashboardQuery(Guid ShopId) : IRequest;
+
+///
+/// EN: POS dashboard DTO with today's stats.
+/// VI: DTO dashboard POS với thống kê hôm nay.
+///
+public record PosDashboardDto(
+ decimal Revenue,
+ int OrderCount,
+ int ItemsSold,
+ decimal AvgOrderValue,
+ List PopularItems,
+ List PaymentBreakdown,
+ List HourlyRevenue,
+ List RecentOrders
+);
+
+///
+/// EN: Popular item sold today.
+/// VI: Sản phẩm bán chạy hôm nay.
+///
+public record PopularItemDto(Guid ProductId, string ProductName, int QuantitySold, decimal Revenue);
+
+///
+/// EN: Payment breakdown by order status.
+/// VI: Phân tích thanh toán theo trạng thái đơn hàng.
+///
+public record PaymentBreakdownDto(string Method, int Count, decimal Amount);
+
+///
+/// EN: Revenue by hour of day.
+/// VI: Doanh thu theo giờ trong ngày.
+///
+public record HourlyRevenueDto(int Hour, decimal Revenue, int OrderCount);
+
+///
+/// EN: Recent order summary.
+/// VI: Tóm tắt đơn hàng gần đây.
+///
+public record RecentOrderDto(
+ Guid Id,
+ decimal TotalAmount,
+ string Status,
+ int ItemCount,
+ DateTime CreatedAt
+);
+
+///
+/// EN: Handler for GetPosDashboardQuery.
+/// VI: Handler cho GetPosDashboardQuery.
+///
+public class GetPosDashboardQueryHandler : IRequestHandler
+{
+ private readonly IDbConnection _connection;
+
+ public GetPosDashboardQueryHandler(IDbConnection connection)
+ {
+ _connection = connection ?? throw new ArgumentNullException(nameof(connection));
+ }
+
+ public async Task Handle(GetPosDashboardQuery request, CancellationToken cancellationToken)
+ {
+ var today = DateTime.UtcNow.Date;
+ var tomorrow = today.AddDays(1);
+ var parameters = new DynamicParameters();
+ parameters.Add("ShopId", request.ShopId);
+ parameters.Add("Today", today);
+ parameters.Add("Tomorrow", tomorrow);
+
+ // EN: Aggregate stats for today / VI: Thống kê tổng hợp hôm nay
+ var aggregateSql = @"
+ SELECT
+ COALESCE(SUM(o.total_amount), 0) AS Revenue,
+ COUNT(*) AS OrderCount,
+ COALESCE(SUM(oi.total_qty), 0) AS ItemsSold,
+ COALESCE(AVG(o.total_amount), 0) AS AvgOrderValue
+ FROM orders o
+ LEFT JOIN (
+ SELECT order_id, SUM(quantity) AS total_qty
+ FROM order_items
+ GROUP BY order_id
+ ) oi ON oi.order_id = o.id
+ WHERE o.shop_id = @ShopId
+ AND o.created_at >= @Today
+ AND o.created_at < @Tomorrow";
+
+ var aggregate = await _connection.QuerySingleAsync(aggregateSql, parameters);
+
+ // EN: Top 5 popular items today / VI: Top 5 sản phẩm bán chạy hôm nay
+ var popularSql = @"
+ SELECT
+ oi.product_id AS ProductId,
+ oi.product_name AS ProductName,
+ SUM(oi.quantity) AS QuantitySold,
+ SUM(oi.unit_price * oi.quantity) AS Revenue
+ FROM order_items oi
+ INNER JOIN orders o ON o.id = oi.order_id
+ WHERE o.shop_id = @ShopId
+ AND o.created_at >= @Today
+ AND o.created_at < @Tomorrow
+ GROUP BY oi.product_id, oi.product_name
+ ORDER BY QuantitySold DESC
+ LIMIT 5";
+
+ var popularItems = (await _connection.QueryAsync(popularSql, parameters)).AsList();
+
+ // EN: Payment breakdown by status / VI: Phân tích theo trạng thái
+ var paymentSql = @"
+ SELECT
+ os.name AS Method,
+ COUNT(*) AS Count,
+ COALESCE(SUM(o.total_amount), 0) AS Amount
+ FROM orders o
+ INNER JOIN order_statuses os ON o.status_id = os.id
+ WHERE o.shop_id = @ShopId
+ AND o.created_at >= @Today
+ AND o.created_at < @Tomorrow
+ GROUP BY os.name";
+
+ var paymentBreakdown = (await _connection.QueryAsync(paymentSql, parameters)).AsList();
+
+ // EN: Hourly revenue today / VI: Doanh thu theo giờ hôm nay
+ var hourlySql = @"
+ SELECT
+ EXTRACT(HOUR FROM o.created_at) AS Hour,
+ COALESCE(SUM(o.total_amount), 0) AS Revenue,
+ COUNT(*) AS OrderCount
+ FROM orders o
+ WHERE o.shop_id = @ShopId
+ AND o.created_at >= @Today
+ AND o.created_at < @Tomorrow
+ GROUP BY EXTRACT(HOUR FROM o.created_at)
+ ORDER BY Hour";
+
+ var hourlyRevenue = (await _connection.QueryAsync(hourlySql, parameters)).AsList();
+
+ // EN: Last 10 recent orders / VI: 10 đơn hàng gần nhất
+ var recentSql = @"
+ SELECT
+ o.id AS Id,
+ o.total_amount AS TotalAmount,
+ os.name AS Status,
+ COUNT(oi.id) AS ItemCount,
+ o.created_at AS CreatedAt
+ FROM orders o
+ INNER JOIN order_statuses os ON o.status_id = os.id
+ LEFT JOIN order_items oi ON oi.order_id = o.id
+ WHERE o.shop_id = @ShopId
+ GROUP BY o.id, o.total_amount, os.name, o.created_at
+ ORDER BY o.created_at DESC
+ LIMIT 10";
+
+ var recentParams = new DynamicParameters();
+ recentParams.Add("ShopId", request.ShopId);
+ var recentOrders = (await _connection.QueryAsync(recentSql, recentParams)).AsList();
+
+ return new PosDashboardDto(
+ aggregate.Revenue,
+ aggregate.OrderCount,
+ aggregate.ItemsSold,
+ aggregate.AvgOrderValue,
+ popularItems,
+ paymentBreakdown,
+ hourlyRevenue,
+ recentOrders);
+ }
+
+ private record AggregateRow(decimal Revenue, int OrderCount, int ItemsSold, decimal AvgOrderValue);
+}
diff --git a/services/order-service-net/src/OrderService.API/Application/Queries/GetRevenueReportQuery.cs b/services/order-service-net/src/OrderService.API/Application/Queries/GetRevenueReportQuery.cs
new file mode 100644
index 00000000..998be89c
--- /dev/null
+++ b/services/order-service-net/src/OrderService.API/Application/Queries/GetRevenueReportQuery.cs
@@ -0,0 +1,116 @@
+// EN: Query to get revenue report grouped by period.
+// VI: Query lấy báo cáo doanh thu theo kỳ.
+
+using System.Data;
+using Dapper;
+using MediatR;
+
+namespace OrderService.API.Application.Queries;
+
+///
+/// EN: Query for revenue report by period.
+/// VI: Query cho báo cáo doanh thu theo kỳ.
+///
+public record GetRevenueReportQuery(
+ string Period,
+ Guid ShopId,
+ DateTime? FromDate = null,
+ DateTime? ToDate = null
+) : IRequest;
+
+///
+/// EN: Revenue report with period breakdown.
+/// VI: Báo cáo doanh thu với phân tích theo kỳ.
+///
+public record RevenueReportDto(
+ string Period,
+ Guid ShopId,
+ decimal TotalRevenue,
+ int TotalOrders,
+ List Data
+);
+
+///
+/// EN: Revenue data for a single period.
+/// VI: Dữ liệu doanh thu cho một kỳ.
+///
+public record RevenuePeriodDto(
+ DateTime PeriodStart,
+ decimal Revenue,
+ int OrderCount,
+ decimal AvgOrderValue
+);
+
+///
+/// EN: Handler for GetRevenueReportQuery.
+/// VI: Handler cho GetRevenueReportQuery.
+///
+public class GetRevenueReportQueryHandler : IRequestHandler
+{
+ private readonly IDbConnection _connection;
+
+ public GetRevenueReportQueryHandler(IDbConnection connection)
+ {
+ _connection = connection ?? throw new ArgumentNullException(nameof(connection));
+ }
+
+ public async Task Handle(GetRevenueReportQuery request, CancellationToken cancellationToken)
+ {
+ var truncExpr = request.Period.ToLowerInvariant() switch
+ {
+ "weekly" => "DATE_TRUNC('week', o.created_at)",
+ "monthly" => "DATE_TRUNC('month', o.created_at)",
+ _ => "DATE_TRUNC('day', o.created_at)" // daily default
+ };
+
+ var (whereClause, parameters) = BuildWhereClause(request.ShopId, request.FromDate, request.ToDate);
+
+ // EN: Revenue grouped by period / VI: Doanh thu nhóm theo kỳ
+ var sql = $@"
+ SELECT
+ {truncExpr} AS PeriodStart,
+ COALESCE(SUM(o.total_amount), 0) AS Revenue,
+ COUNT(*) AS OrderCount,
+ COALESCE(AVG(o.total_amount), 0) AS AvgOrderValue
+ FROM orders o
+ {whereClause}
+ GROUP BY {truncExpr}
+ ORDER BY PeriodStart";
+
+ var data = (await _connection.QueryAsync(sql, parameters)).AsList();
+
+ var totalRevenue = data.Sum(d => d.Revenue);
+ var totalOrders = data.Sum(d => d.OrderCount);
+
+ return new RevenueReportDto(
+ request.Period.ToLowerInvariant(),
+ request.ShopId,
+ totalRevenue,
+ totalOrders,
+ data);
+ }
+
+ private static (string whereClause, DynamicParameters parameters) BuildWhereClause(
+ Guid shopId,
+ DateTime? fromDate,
+ DateTime? toDate)
+ {
+ var conditions = new List { "o.shop_id = @ShopId" };
+ var parameters = new DynamicParameters();
+ parameters.Add("ShopId", shopId);
+
+ if (fromDate.HasValue)
+ {
+ conditions.Add("o.created_at >= @FromDate");
+ parameters.Add("FromDate", fromDate.Value);
+ }
+
+ if (toDate.HasValue)
+ {
+ conditions.Add("o.created_at <= @ToDate");
+ parameters.Add("ToDate", toDate.Value);
+ }
+
+ return ("WHERE " + string.Join(" AND ", conditions), parameters);
+ }
+}
diff --git a/services/order-service-net/src/OrderService.API/Application/Queries/GetTopProductsQuery.cs b/services/order-service-net/src/OrderService.API/Application/Queries/GetTopProductsQuery.cs
new file mode 100644
index 00000000..432186f8
--- /dev/null
+++ b/services/order-service-net/src/OrderService.API/Application/Queries/GetTopProductsQuery.cs
@@ -0,0 +1,92 @@
+// EN: Query to get top selling products.
+// VI: Query lấy sản phẩm bán chạy nhất.
+
+using System.Data;
+using Dapper;
+using MediatR;
+
+namespace OrderService.API.Application.Queries;
+
+///
+/// EN: Query for top selling products by shop.
+/// VI: Query cho sản phẩm bán chạy theo shop.
+///
+public record GetTopProductsQuery(
+ Guid ShopId,
+ int Limit = 10,
+ DateTime? FromDate = null,
+ DateTime? ToDate = null
+) : IRequest>;
+
+///
+/// EN: Top product DTO.
+/// VI: DTO sản phẩm bán chạy.
+///
+public record TopProductDto(
+ Guid ProductId,
+ string ProductName,
+ int TotalQuantity,
+ decimal TotalRevenue,
+ int OrderCount
+);
+
+///
+/// EN: Handler for GetTopProductsQuery.
+/// VI: Handler cho GetTopProductsQuery.
+///
+public class GetTopProductsQueryHandler : IRequestHandler>
+{
+ private readonly IDbConnection _connection;
+
+ public GetTopProductsQueryHandler(IDbConnection connection)
+ {
+ _connection = connection ?? throw new ArgumentNullException(nameof(connection));
+ }
+
+ public async Task> Handle(GetTopProductsQuery request, CancellationToken cancellationToken)
+ {
+ var (whereClause, parameters) = BuildWhereClause(request.ShopId, request.FromDate, request.ToDate);
+ parameters.Add("Limit", request.Limit);
+
+ var sql = $@"
+ SELECT
+ oi.product_id AS ProductId,
+ oi.product_name AS ProductName,
+ SUM(oi.quantity) AS TotalQuantity,
+ SUM(oi.unit_price * oi.quantity) AS TotalRevenue,
+ COUNT(DISTINCT o.id) AS OrderCount
+ FROM order_items oi
+ INNER JOIN orders o ON o.id = oi.order_id
+ {whereClause}
+ GROUP BY oi.product_id, oi.product_name
+ ORDER BY TotalQuantity DESC
+ LIMIT @Limit";
+
+ var result = (await _connection.QueryAsync(sql, parameters)).AsList();
+ return result;
+ }
+
+ private static (string whereClause, DynamicParameters parameters) BuildWhereClause(
+ Guid shopId,
+ DateTime? fromDate,
+ DateTime? toDate)
+ {
+ var conditions = new List { "o.shop_id = @ShopId" };
+ var parameters = new DynamicParameters();
+ parameters.Add("ShopId", shopId);
+
+ if (fromDate.HasValue)
+ {
+ conditions.Add("o.created_at >= @FromDate");
+ parameters.Add("FromDate", fromDate.Value);
+ }
+
+ if (toDate.HasValue)
+ {
+ conditions.Add("o.created_at <= @ToDate");
+ parameters.Add("ToDate", toDate.Value);
+ }
+
+ return ("WHERE " + string.Join(" AND ", conditions), parameters);
+ }
+}
diff --git a/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs b/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs
index 3202bcf3..7563cbae 100644
--- a/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs
+++ b/services/order-service-net/src/OrderService.API/Controllers/OrdersController.cs
@@ -173,6 +173,26 @@ public class OrdersController : ControllerBase
return Ok(result);
}
+ ///
+ /// EN: Get POS dashboard stats for today.
+ /// VI: Lấy thống kê dashboard POS cho hôm nay.
+ ///
+ [HttpGet("dashboard")]
+ [ProducesResponseType(typeof(PosDashboardDto), StatusCodes.Status200OK)]
+ public async Task> GetPosDashboard(
+ [FromQuery] Guid shopId,
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation(
+ "EN: Getting POS dashboard for shop {ShopId} / VI: Lấy dashboard POS cho shop {ShopId}",
+ shopId);
+
+ var query = new GetPosDashboardQuery(shopId);
+ var result = await _mediator.Send(query, cancellationToken);
+
+ return Ok(result);
+ }
+
///
/// EN: Get orders by customer.
/// VI: Lấy orders theo khách hàng.
diff --git a/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs b/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs
new file mode 100644
index 00000000..4c05bdf0
--- /dev/null
+++ b/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs
@@ -0,0 +1,76 @@
+// EN: Reports REST API Controller.
+// VI: Controller REST API cho Reports.
+
+using Asp.Versioning;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using OrderService.API.Application.Queries;
+
+namespace OrderService.API.Controllers;
+
+///
+/// EN: Reports API Controller for revenue and product analytics.
+/// VI: Controller API Reports cho phân tích doanh thu và sản phẩm.
+///
+[ApiController]
+[ApiVersion("1.0")]
+[Route("api/v{version:apiVersion}/reports")]
+public class ReportsController : ControllerBase
+{
+ private readonly IMediator _mediator;
+ private readonly ILogger _logger;
+
+ public ReportsController(
+ IMediator mediator,
+ ILogger logger)
+ {
+ _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// EN: Get revenue report grouped by period (daily, weekly, monthly).
+ /// VI: Lấy báo cáo doanh thu theo kỳ (ngày, tuần, tháng).
+ ///
+ [HttpGet("revenue")]
+ [ProducesResponseType(typeof(RevenueReportDto), StatusCodes.Status200OK)]
+ public async Task> GetRevenueReport(
+ [FromQuery] Guid shopId,
+ [FromQuery] string period = "daily",
+ [FromQuery] DateTime? fromDate = null,
+ [FromQuery] DateTime? toDate = null,
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation(
+ "EN: Getting revenue report for shop {ShopId}, period {Period} / VI: Lấy báo cáo doanh thu cho shop {ShopId}, kỳ {Period}",
+ shopId, period);
+
+ var query = new GetRevenueReportQuery(period, shopId, fromDate, toDate);
+ var result = await _mediator.Send(query, cancellationToken);
+
+ return Ok(result);
+ }
+
+ ///
+ /// EN: Get top selling products.
+ /// VI: Lấy sản phẩm bán chạy nhất.
+ ///
+ [HttpGet("top-products")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> GetTopProducts(
+ [FromQuery] Guid shopId,
+ [FromQuery] int limit = 10,
+ [FromQuery] DateTime? fromDate = null,
+ [FromQuery] DateTime? toDate = null,
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation(
+ "EN: Getting top {Limit} products for shop {ShopId} / VI: Lấy top {Limit} sản phẩm cho shop {ShopId}",
+ limit, shopId);
+
+ var query = new GetTopProductsQuery(shopId, limit, fromDate, toDate);
+ var result = await _mediator.Send(query, cancellationToken);
+
+ return Ok(result);
+ }
+}