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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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() ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user