feat(order-service): add dashboard and reporting endpoints

- GET /api/v1/orders/dashboard — POS dashboard stats (revenue, orders,
  items sold, popular items, payment breakdown, hourly revenue, recent orders)
- GET /api/v1/reports/revenue — Revenue report grouped by daily/weekly/monthly
- GET /api/v1/reports/top-products — Top selling products by quantity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-04 10:36:09 +07:00
parent 37042b48b7
commit 9b44e88a6a
13 changed files with 1006 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// EN: Command to update shop settings.
/// VI: Command để cập nhật cài đặt shop.
/// </summary>
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<string>? OpenDays { get; init; }
}
/// <summary>
/// EN: Request model for updating shop features.
/// VI: Model request để cập nhật tính năng shop.
/// </summary>
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; }
}
/// <summary>
/// EN: Handler for updating shop settings.
/// VI: Handler để cập nhật cài đặt shop.
/// </summary>
public class UpdateShopSettingsCommandHandler : IRequestHandler<UpdateShopSettingsCommand>
{
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<DayOfWeek>(d, ignoreCase: true)).ToList()
: currentHours?.OpenDays ?? Enum.GetValues<DayOfWeek>().ToList();
var hours = new OperatingHours
{
OpenTime = openTime,
CloseTime = closeTime,
OpenDays = openDays
};
shop.UpdateOperatingHours(hours);
}
_shopRepository.Update(shop);
await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public record GetDevicesQuery : IRequest<IReadOnlyList<DeviceDto>>;
/// <summary>
/// EN: Device DTO.
/// VI: DTO thiết bị.
/// </summary>
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; }
}
/// <summary>
/// EN: Handler for getting registered devices.
/// VI: Handler để lấy danh sách thiết bị đã đăng ký.
/// </summary>
public class GetDevicesQueryHandler : IRequestHandler<GetDevicesQuery, IReadOnlyList<DeviceDto>>
{
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<IReadOnlyList<DeviceDto>> 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<DeviceDto>();
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();
}
}

View File

@@ -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;
/// <summary>
/// EN: Query to get shop settings by shop ID.
/// VI: Query để lấy cài đặt shop theo shop ID.
/// </summary>
public record GetShopSettingsQuery(Guid ShopId) : IRequest<ShopSettingsDto?>;
/// <summary>
/// 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.
/// </summary>
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<string> OpenDays { get; init; } = [];
}
/// <summary>
/// EN: Shop features DTO.
/// VI: DTO tính năng shop.
/// </summary>
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; }
}
/// <summary>
/// EN: Handler for getting shop settings.
/// VI: Handler để lấy cài đặt shop.
/// </summary>
public class GetShopSettingsQueryHandler : IRequestHandler<GetShopSettingsQuery, ShopSettingsDto?>
{
private readonly IShopRepository _shopRepository;
public GetShopSettingsQueryHandler(IShopRepository shopRepository)
{
_shopRepository = shopRepository;
}
public async Task<ShopSettingsDto?> 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() ?? []
};
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public record GetShopStatsQuery : IRequest<IReadOnlyList<ShopStatsDto>>;
/// <summary>
/// EN: Per-shop stats DTO with placeholder values.
/// VI: DTO thống kê từng shop với giá trị placeholder.
/// </summary>
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; }
}
/// <summary>
/// EN: Handler for getting per-shop stats.
/// VI: Handler để lấy thống kê từng shop.
/// </summary>
public class GetShopStatsQueryHandler : IRequestHandler<GetShopStatsQuery, IReadOnlyList<ShopStatsDto>>
{
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<IReadOnlyList<ShopStatsDto>> 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();
}
}

View File

@@ -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;
/// <summary>
/// EN: Query to get available staff roles.
/// VI: Query để lấy danh sách vai trò nhân viên có sẵn.
/// </summary>
public record GetStaffRolesQuery : IRequest<IReadOnlyList<StaffRoleDto>>;
/// <summary>
/// EN: Staff role DTO.
/// VI: DTO vai trò nhân viên.
/// </summary>
public record StaffRoleDto
{
public int Id { get; init; }
public string Name { get; init; } = null!;
}
/// <summary>
/// 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.
/// </summary>
public class GetStaffRolesQueryHandler : IRequestHandler<GetStaffRolesQuery, IReadOnlyList<StaffRoleDto>>
{
public Task<IReadOnlyList<StaffRoleDto>> 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<StaffRole>()
.Select(r => new StaffRoleDto
{
Id = r.Id,
Name = r.Name
})
.OrderBy(r => r.Id)
.ToList();
return Task.FromResult<IReadOnlyList<StaffRoleDto>>(roles);
}
}

View File

@@ -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;
/// <summary>
/// EN: Controller for device management.
/// VI: Controller để quản lý thiết bị.
/// </summary>
[ApiController]
[Route("api/v1/devices")]
[Authorize]
public class DevicesController : ControllerBase
{
private readonly IMediator _mediator;
public DevicesController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<DeviceDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetDevices()
{
var result = await _mediator.Send(new GetDevicesQuery());
return Ok(result);
}
}

View File

@@ -27,6 +27,63 @@ public class ShopsController : ControllerBase
_logger = logger;
}
/// <summary>
/// EN: Get per-shop stats for current merchant.
/// VI: Lấy thống kê từng shop của merchant hiện tại.
/// </summary>
[HttpGet("stats")]
[ProducesResponseType(typeof(IReadOnlyList<ShopStatsDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetStats()
{
var result = await _mediator.Send(new GetShopStatsQuery());
return Ok(result);
}
/// <summary>
/// EN: Get shop settings (features, operating hours).
/// VI: Lấy cài đặt shop (tính năng, giờ hoạt động).
/// </summary>
[HttpGet("{shopId:guid}/settings")]
[ProducesResponseType(typeof(ShopSettingsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSettings(Guid shopId)
{
var result = await _mediator.Send(new GetShopSettingsQuery(shopId));
if (result == null)
{
return NotFound(new { message = "Shop not found" });
}
return Ok(result);
}
/// <summary>
/// EN: Update shop settings (features, operating hours).
/// VI: Cập nhật cài đặt shop (tính năng, giờ hoạt động).
/// </summary>
[HttpPut("{shopId:guid}/settings")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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 });
}
}
/// <summary>
/// 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; }
}
/// <summary>
/// EN: Request model for updating shop settings.
/// VI: Model request để cập nhật cài đặt shop.
/// </summary>
public record UpdateShopSettingsBody
{
public UpdateShopFeaturesRequest? Features { get; init; }
public string? OpenTime { get; init; }
public string? CloseTime { get; init; }
public List<string>? OpenDays { get; init; }
}

View File

@@ -141,6 +141,19 @@ public class StaffPublicController : ControllerBase
_logger = logger;
}
/// <summary>
/// EN: Get available staff roles.
/// VI: Lấy danh sách vai trò nhân viên có sẵn.
/// </summary>
[HttpGet("roles")]
[AllowAnonymous]
[ProducesResponseType(typeof(IReadOnlyList<StaffRoleDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetRoles()
{
var result = await _mediator.Send(new GetStaffRolesQuery());
return Ok(result);
}
/// <summary>
/// EN: Accept a staff invitation.
/// VI: Chấp nhận lời mời làm nhân viên.

View File

@@ -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;
/// <summary>
/// EN: Query for POS dashboard data.
/// VI: Query cho dữ liệu dashboard POS.
/// </summary>
public record GetPosDashboardQuery(Guid ShopId) : IRequest<PosDashboardDto>;
/// <summary>
/// EN: POS dashboard DTO with today's stats.
/// VI: DTO dashboard POS với thống kê hôm nay.
/// </summary>
public record PosDashboardDto(
decimal Revenue,
int OrderCount,
int ItemsSold,
decimal AvgOrderValue,
List<PopularItemDto> PopularItems,
List<PaymentBreakdownDto> PaymentBreakdown,
List<HourlyRevenueDto> HourlyRevenue,
List<RecentOrderDto> RecentOrders
);
/// <summary>
/// EN: Popular item sold today.
/// VI: Sản phẩm bán chạy hôm nay.
/// </summary>
public record PopularItemDto(Guid ProductId, string ProductName, int QuantitySold, decimal Revenue);
/// <summary>
/// EN: Payment breakdown by order status.
/// VI: Phân tích thanh toán theo trạng thái đơn hàng.
/// </summary>
public record PaymentBreakdownDto(string Method, int Count, decimal Amount);
/// <summary>
/// EN: Revenue by hour of day.
/// VI: Doanh thu theo giờ trong ngày.
/// </summary>
public record HourlyRevenueDto(int Hour, decimal Revenue, int OrderCount);
/// <summary>
/// EN: Recent order summary.
/// VI: Tóm tắt đơn hàng gần đây.
/// </summary>
public record RecentOrderDto(
Guid Id,
decimal TotalAmount,
string Status,
int ItemCount,
DateTime CreatedAt
);
/// <summary>
/// EN: Handler for GetPosDashboardQuery.
/// VI: Handler cho GetPosDashboardQuery.
/// </summary>
public class GetPosDashboardQueryHandler : IRequestHandler<GetPosDashboardQuery, PosDashboardDto>
{
private readonly IDbConnection _connection;
public GetPosDashboardQueryHandler(IDbConnection connection)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
}
public async Task<PosDashboardDto> 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<AggregateRow>(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<PopularItemDto>(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<PaymentBreakdownDto>(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<HourlyRevenueDto>(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<RecentOrderDto>(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);
}

View File

@@ -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;
/// <summary>
/// EN: Query for revenue report by period.
/// VI: Query cho báo cáo doanh thu theo kỳ.
/// </summary>
public record GetRevenueReportQuery(
string Period,
Guid ShopId,
DateTime? FromDate = null,
DateTime? ToDate = null
) : IRequest<RevenueReportDto>;
/// <summary>
/// EN: Revenue report with period breakdown.
/// VI: Báo cáo doanh thu với phân tích theo kỳ.
/// </summary>
public record RevenueReportDto(
string Period,
Guid ShopId,
decimal TotalRevenue,
int TotalOrders,
List<RevenuePeriodDto> Data
);
/// <summary>
/// EN: Revenue data for a single period.
/// VI: Dữ liệu doanh thu cho một kỳ.
/// </summary>
public record RevenuePeriodDto(
DateTime PeriodStart,
decimal Revenue,
int OrderCount,
decimal AvgOrderValue
);
/// <summary>
/// EN: Handler for GetRevenueReportQuery.
/// VI: Handler cho GetRevenueReportQuery.
/// </summary>
public class GetRevenueReportQueryHandler : IRequestHandler<GetRevenueReportQuery, RevenueReportDto>
{
private readonly IDbConnection _connection;
public GetRevenueReportQueryHandler(IDbConnection connection)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
}
public async Task<RevenueReportDto> 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<RevenuePeriodDto>(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<string> { "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);
}
}

View File

@@ -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;
/// <summary>
/// EN: Query for top selling products by shop.
/// VI: Query cho sản phẩm bán chạy theo shop.
/// </summary>
public record GetTopProductsQuery(
Guid ShopId,
int Limit = 10,
DateTime? FromDate = null,
DateTime? ToDate = null
) : IRequest<List<TopProductDto>>;
/// <summary>
/// EN: Top product DTO.
/// VI: DTO sản phẩm bán chạy.
/// </summary>
public record TopProductDto(
Guid ProductId,
string ProductName,
int TotalQuantity,
decimal TotalRevenue,
int OrderCount
);
/// <summary>
/// EN: Handler for GetTopProductsQuery.
/// VI: Handler cho GetTopProductsQuery.
/// </summary>
public class GetTopProductsQueryHandler : IRequestHandler<GetTopProductsQuery, List<TopProductDto>>
{
private readonly IDbConnection _connection;
public GetTopProductsQueryHandler(IDbConnection connection)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
}
public async Task<List<TopProductDto>> 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<TopProductDto>(sql, parameters)).AsList();
return result;
}
private static (string whereClause, DynamicParameters parameters) BuildWhereClause(
Guid shopId,
DateTime? fromDate,
DateTime? toDate)
{
var conditions = new List<string> { "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);
}
}

View File

@@ -173,6 +173,26 @@ public class OrdersController : ControllerBase
return Ok(result);
}
/// <summary>
/// EN: Get POS dashboard stats for today.
/// VI: Lấy thống kê dashboard POS cho hôm nay.
/// </summary>
[HttpGet("dashboard")]
[ProducesResponseType(typeof(PosDashboardDto), StatusCodes.Status200OK)]
public async Task<ActionResult<PosDashboardDto>> 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);
}
/// <summary>
/// EN: Get orders by customer.
/// VI: Lấy orders theo khách hàng.

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/reports")]
public class ReportsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<ReportsController> _logger;
public ReportsController(
IMediator mediator,
ILogger<ReportsController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 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).
/// </summary>
[HttpGet("revenue")]
[ProducesResponseType(typeof(RevenueReportDto), StatusCodes.Status200OK)]
public async Task<ActionResult<RevenueReportDto>> 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);
}
/// <summary>
/// EN: Get top selling products.
/// VI: Lấy sản phẩm bán chạy nhất.
/// </summary>
[HttpGet("top-products")]
[ProducesResponseType(typeof(List<TopProductDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<TopProductDto>>> 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);
}
}