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); + } +}