feat: implement admin order stats export and mining overview

Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-02-23 12:42:55 +00:00
parent d68a47f93a
commit 9e5b1018b4
5 changed files with 423 additions and 71 deletions

View File

@@ -1,5 +1,8 @@
using MediatR;
using MiningService.Domain.AggregatesModel.MinerAggregate;
using MiningService.Domain.AggregatesModel.CircleAggregate;
using MiningService.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace MiningService.API.Application.Queries;
@@ -21,25 +24,39 @@ public record AdminOverviewDto(
public class GetAdminOverviewQueryHandler : IRequestHandler<GetAdminOverviewQuery, AdminOverviewDto>
{
private readonly IMinerRepository _minerRepository;
private readonly MiningServiceContext _context;
public GetAdminOverviewQueryHandler(IMinerRepository minerRepository)
public GetAdminOverviewQueryHandler(MiningServiceContext context)
{
_minerRepository = minerRepository;
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<AdminOverviewDto> Handle(GetAdminOverviewQuery request, CancellationToken cancellationToken)
{
// Note: In production, use Dapper for read-optimized queries
// This is a simplified version
var totalMiners = await _context.Miners.CountAsync(cancellationToken);
var activeMiners = await _context.Miners
.CountAsync(miner => miner.Status == MinerStatus.Active, cancellationToken);
var minersWithActiveSession = await _context.Miners
.CountAsync(miner => miner.ActiveSession != null && miner.ActiveSession.Status == MiningSessionStatus.Active, cancellationToken);
var totalPointsMined = await _context.Miners
.SumAsync(miner => (decimal?)miner.TotalMinedPoints, cancellationToken) ?? 0m;
var totalCircles = await _context.Circles.CountAsync(cancellationToken);
var validCircles = await _context.Circles
.CountAsync(circle => circle.Status == CircleStatus.Active, cancellationToken);
var totalReferrals = await _context.Referrals.CountAsync(cancellationToken);
var activeReferrals = await _context.Referrals
.CountAsync(referral => referral.IsActive, cancellationToken);
return new AdminOverviewDto(
TotalMiners: 0, // TODO: Implement with Dapper
ActiveMiners: 0,
MinersWithActiveSession: 0,
TotalPointsMined: 0,
TotalCircles: 0,
ValidCircles: 0,
TotalReferrals: 0,
ActiveReferrals: 0);
TotalMiners: totalMiners,
ActiveMiners: activeMiners,
MinersWithActiveSession: minersWithActiveSession,
TotalPointsMined: totalPointsMined,
TotalCircles: totalCircles,
ValidCircles: validCircles,
TotalReferrals: totalReferrals,
ActiveReferrals: activeReferrals);
}
}

View File

@@ -0,0 +1,134 @@
// EN: Query to list all orders for admin with advanced filtering.
// VI: Query liệt kê tất cả đơn hàng cho admin với bộ lọc nâng cao.
using System.Data;
using Dapper;
using MediatR;
using OrderService.API.Application.DTOs;
namespace OrderService.API.Application.Queries;
/// <summary>
/// EN: Query to list all orders for admins.
/// VI: Query để liệt kê tất cả đơn hàng cho admin.
/// </summary>
public record AdminListOrdersQuery(
Guid? ShopId,
Guid? CustomerId,
string? Status,
DateTime? FromDate,
DateTime? ToDate,
decimal? MinAmount,
decimal? MaxAmount,
int Page = 1,
int PageSize = 20
) : IRequest<PagedResult<OrderSummaryDto>>;
/// <summary>
/// EN: Handler for AdminListOrdersQuery using Dapper.
/// VI: Handler cho AdminListOrdersQuery dùng Dapper.
/// </summary>
public class AdminListOrdersQueryHandler : IRequestHandler<AdminListOrdersQuery, PagedResult<OrderSummaryDto>>
{
private readonly IDbConnection _connection;
public AdminListOrdersQueryHandler(IDbConnection connection)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
}
public async Task<PagedResult<OrderSummaryDto>> Handle(
AdminListOrdersQuery request,
CancellationToken cancellationToken)
{
var (whereClause, parameters) = BuildWhereClause(request);
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);
var listSql = $@"
SELECT
o.id AS Id,
o.shop_id AS ShopId,
o.customer_id AS CustomerId,
os.name AS Status,
o.total_amount AS TotalAmount,
(SELECT COUNT(*) FROM order_items oi WHERE oi.order_id = o.id) AS ItemCount,
o.created_at AS CreatedAt
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>(listSql, parameters);
return new PagedResult<OrderSummaryDto>(
orders.ToList(),
totalCount,
request.PageSize,
request.Page);
}
private static (string whereClause, DynamicParameters parameters) BuildWhereClause(AdminListOrdersQuery request)
{
var conditions = new List<string>();
var parameters = new DynamicParameters();
if (request.ShopId.HasValue)
{
conditions.Add("o.shop_id = @ShopId");
parameters.Add("ShopId", request.ShopId.Value);
}
if (request.CustomerId.HasValue)
{
conditions.Add("o.customer_id = @CustomerId");
parameters.Add("CustomerId", request.CustomerId.Value);
}
if (!string.IsNullOrWhiteSpace(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);
}
if (request.MinAmount.HasValue)
{
conditions.Add("o.total_amount >= @MinAmount");
parameters.Add("MinAmount", request.MinAmount.Value);
}
if (request.MaxAmount.HasValue)
{
conditions.Add("o.total_amount <= @MaxAmount");
parameters.Add("MaxAmount", request.MaxAmount.Value);
}
var whereClause = conditions.Count == 0
? string.Empty
: "WHERE " + string.Join(" AND ", conditions);
return (whereClause, parameters);
}
}

View File

@@ -0,0 +1,128 @@
// EN: Query to export orders for admin.
// VI: Query xuất đơn hàng cho admin.
using System.Data;
using Dapper;
using MediatR;
namespace OrderService.API.Application.Queries;
/// <summary>
/// EN: Query to export orders in CSV-friendly structure.
/// VI: Query để xuất đơn hàng theo cấu trúc phù hợp CSV.
/// </summary>
public record ExportOrdersQuery(
Guid? ShopId,
DateTime? FromDate,
DateTime? ToDate
) : IRequest<ExportOrdersResult>;
/// <summary>
/// EN: Export payload result.
/// VI: Kết quả payload export.
/// </summary>
public record ExportOrdersResult(string FileName, string CsvContent);
/// <summary>
/// EN: Handler for ExportOrdersQuery.
/// VI: Handler cho ExportOrdersQuery.
/// </summary>
public class ExportOrdersQueryHandler : IRequestHandler<ExportOrdersQuery, ExportOrdersResult>
{
private readonly IDbConnection _connection;
public ExportOrdersQueryHandler(IDbConnection connection)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
}
public async Task<ExportOrdersResult> Handle(
ExportOrdersQuery request,
CancellationToken cancellationToken)
{
var (whereClause, parameters) = BuildWhereClause(request.ShopId, request.FromDate, request.ToDate);
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
FROM orders o
INNER JOIN order_statuses os ON o.status_id = os.id
{whereClause}
ORDER BY o.created_at DESC";
var rows = await _connection.QueryAsync<ExportOrderRow>(sql, parameters);
var csvLines = new List<string>
{
"OrderId,ShopId,CustomerId,Status,TotalAmount,CreatedAt",
};
csvLines.AddRange(rows.Select(row =>
$"{row.Id},{row.ShopId},{row.CustomerId},{EscapeCsv(row.Status)},{row.TotalAmount},{row.CreatedAt:O}"));
var fileName = $"orders_{DateTime.UtcNow:yyyyMMdd_HHmmss}.csv";
var csvContent = string.Join(Environment.NewLine, csvLines);
return new ExportOrdersResult(fileName, csvContent);
}
private static (string whereClause, DynamicParameters parameters) BuildWhereClause(
Guid? shopId,
DateTime? fromDate,
DateTime? toDate)
{
var conditions = new List<string>();
var parameters = new DynamicParameters();
if (shopId.HasValue)
{
conditions.Add("o.shop_id = @ShopId");
parameters.Add("ShopId", shopId.Value);
}
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);
}
var whereClause = conditions.Count == 0
? string.Empty
: "WHERE " + string.Join(" AND ", conditions);
return (whereClause, parameters);
}
private static string EscapeCsv(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
{
return $"\"{value.Replace("\"", "\"\"")}\"";
}
return value;
}
private record ExportOrderRow(
Guid Id,
Guid ShopId,
Guid? CustomerId,
string Status,
decimal TotalAmount,
DateTime CreatedAt);
}

View File

@@ -0,0 +1,113 @@
// EN: Query to get aggregate order statistics for admin.
// VI: Query lấy thống kê tổng hợp đơn hàng cho admin.
using System.Data;
using Dapper;
using MediatR;
namespace OrderService.API.Application.Queries;
/// <summary>
/// EN: Query for order statistics.
/// VI: Query cho thống kê đơn hàng.
/// </summary>
public record GetOrderStatsQuery(
Guid? ShopId,
DateTime? FromDate,
DateTime? ToDate
) : IRequest<OrderStatsDto>;
/// <summary>
/// EN: Order statistics DTO.
/// VI: DTO thống kê đơn hàng.
/// </summary>
public record OrderStatsDto(
int TotalOrders,
decimal TotalRevenue,
decimal AverageOrderValue,
Dictionary<string, int> OrdersByStatus
);
/// <summary>
/// EN: Handler for GetOrderStatsQuery.
/// VI: Handler cho GetOrderStatsQuery.
/// </summary>
public class GetOrderStatsQueryHandler : IRequestHandler<GetOrderStatsQuery, OrderStatsDto>
{
private readonly IDbConnection _connection;
public GetOrderStatsQueryHandler(IDbConnection connection)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
}
public async Task<OrderStatsDto> Handle(GetOrderStatsQuery request, CancellationToken cancellationToken)
{
var (whereClause, parameters) = BuildWhereClause(request.ShopId, request.FromDate, request.ToDate);
var aggregateSql = $@"
SELECT
COUNT(*) AS TotalOrders,
COALESCE(SUM(o.total_amount), 0) AS TotalRevenue,
COALESCE(AVG(o.total_amount), 0) AS AverageOrderValue
FROM orders o
INNER JOIN order_statuses os ON o.status_id = os.id
{whereClause}";
var aggregate = await _connection.QuerySingleAsync<OrderStatsAggregateRow>(aggregateSql, parameters);
var statusSql = $@"
SELECT
os.name AS Status,
COUNT(*) AS Total
FROM orders o
INNER JOIN order_statuses os ON o.status_id = os.id
{whereClause}
GROUP BY os.name";
var statusRows = await _connection.QueryAsync<OrderStatusCountRow>(statusSql, parameters);
var byStatus = statusRows.ToDictionary(row => row.Status, row => row.Total, StringComparer.OrdinalIgnoreCase);
return new OrderStatsDto(
aggregate.TotalOrders,
aggregate.TotalRevenue,
aggregate.AverageOrderValue,
byStatus);
}
private static (string whereClause, DynamicParameters parameters) BuildWhereClause(
Guid? shopId,
DateTime? fromDate,
DateTime? toDate)
{
var conditions = new List<string>();
var parameters = new DynamicParameters();
if (shopId.HasValue)
{
conditions.Add("o.shop_id = @ShopId");
parameters.Add("ShopId", shopId.Value);
}
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);
}
var whereClause = conditions.Count == 0
? string.Empty
: "WHERE " + string.Join(" AND ", conditions);
return (whereClause, parameters);
}
private record OrderStatsAggregateRow(int TotalOrders, decimal TotalRevenue, decimal AverageOrderValue);
private record OrderStatusCountRow(string Status, int Total);
}

View File

@@ -47,38 +47,19 @@ public class AdminOrdersController : ControllerBase
[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);
}
var query = new AdminListOrdersQuery(
shopId,
customerId,
status,
fromDate,
toDate,
minAmount,
maxAmount,
page,
pageSize);
// 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));
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}
/// <summary>
@@ -95,16 +76,8 @@ public class AdminOrdersController : ControllerBase
{
_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>()
);
var query = new GetOrderStatsQuery(shopId, fromDate, toDate);
var stats = await _mediator.Send(query, cancellationToken);
return Ok(stats);
}
@@ -122,23 +95,10 @@ public class AdminOrdersController : ControllerBase
{
_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);
var query = new ExportOrdersQuery(shopId, fromDate, toDate);
var export = await _mediator.Send(query, cancellationToken);
var bytes = System.Text.Encoding.UTF8.GetBytes(export.CsvContent);
return File(bytes, "text/csv", $"orders_{DateTime.UtcNow:yyyyMMdd}.csv");
return File(bytes, "text/csv", export.FileName);
}
}
/// <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
);