diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index 0ab0a9de..6aed87d1 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -47,6 +47,68 @@ public class PosDataService } } + /// + /// EN: Robust list deserialization — handles plain arrays, PagedResult wrappers, and ApiResponse envelopes. + /// VI: Deserialize list linh hoạt — xử lý array thuần, PagedResult wrapper, và ApiResponse envelope. + /// + private async Task> GetListFromApiAsync(string url) + { + AttachToken(); + var resp = await _http.GetAsync(url); + if (!resp.IsSuccessStatusCode) return new(); + var json = await resp.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(json)) return new(); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Case 1: plain array [...] + if (root.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(json, _jsonOptions) ?? new(); + + // Case 2: { "items": [...] } (PagedResult) + if (root.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(items.GetRawText(), _jsonOptions) ?? new(); + + // Case 3: { "data": { "items": [...] } } (ApiResponse) + // Case 4: { "data": [...] } (ApiResponse) + if (root.TryGetProperty("data", out var data)) + { + if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("items", out var dataItems) && dataItems.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(dataItems.GetRawText(), _jsonOptions) ?? new(); + if (data.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(data.GetRawText(), _jsonOptions) ?? new(); + } + + return new(); + } + + /// + /// EN: Robust single-object deserialization — handles plain objects and ApiResponse envelopes. + /// VI: Deserialize đối tượng đơn linh hoạt — xử lý object thuần và ApiResponse envelope. + /// + private async Task GetObjectFromApiAsync(string url) where T : class + { + AttachToken(); + var resp = await _http.GetAsync(url); + if (!resp.IsSuccessStatusCode) return null; + var json = await resp.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(json)) return null; + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Case 1: { "data": {...} } (ApiResponse envelope) + if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object) + return JsonSerializer.Deserialize(data.GetRawText(), _jsonOptions); + + // Case 2: plain object + if (root.ValueKind == JsonValueKind.Object) + return JsonSerializer.Deserialize(json, _jsonOptions); + + return null; + } + public record ShopInfo(Guid Id, string Name, string Slug, string? Description, string? Phone, string? Email, string? Category, string? Status, Guid? MerchantId = null); public record ProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description, string? Category, int? DurationMinutes); public record CategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder); @@ -55,25 +117,25 @@ public class PosDataService public record StaffInfo(Guid Id, Guid? UserId, string? EmployeeCode, string? Phone, string? Email, DateTime? JoinedAt, DateTime? TerminatedAt, string? Role, string? Status, string? ShopName); public async Task> GetShopsAsync() - { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/shops", _jsonOptions) ?? new(); } + => await GetListFromApiAsync("api/bff/shops"); public async Task GetShopByIdAsync(Guid shopId) - { AttachToken(); return await _http.GetFromJsonAsync($"api/bff/shops/{shopId}", _jsonOptions); } + => await GetObjectFromApiAsync($"api/bff/shops/{shopId}"); public async Task> GetProductsAsync(Guid shopId) - { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/products", _jsonOptions) ?? new(); } + => await GetListFromApiAsync($"api/bff/shops/{shopId}/products"); public async Task> GetCategoriesAsync(Guid shopId) - { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/categories", _jsonOptions) ?? new(); } + => await GetListFromApiAsync($"api/bff/shops/{shopId}/categories"); public async Task> GetTablesAsync(Guid shopId) - { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/tables", _jsonOptions) ?? new(); } + => await GetListFromApiAsync($"api/bff/shops/{shopId}/tables"); public async Task> GetAppointmentsAsync(Guid shopId) - { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/appointments", _jsonOptions) ?? new(); } + => await GetListFromApiAsync($"api/bff/shops/{shopId}/appointments"); public async Task> GetStaffAsync() - { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/staff", _jsonOptions) ?? new(); } + => await GetListFromApiAsync("api/bff/staff"); // ═══ ADMIN-LEVEL PRODUCT/CATEGORY METHODS ═══ @@ -88,16 +150,14 @@ public class PosDataService public async Task> GetAllProductsAsync(Guid? shopId = null) { - AttachToken(); var url = shopId.HasValue ? $"api/bff/products?shopId={shopId}" : "api/bff/products"; - return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + return await GetListFromApiAsync(url); } public async Task> GetAllCategoriesAsync(Guid? shopId = null) { - AttachToken(); var url = shopId.HasValue ? $"api/bff/categories?shopId={shopId}" : "api/bff/categories"; - return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + return await GetListFromApiAsync(url); } public async Task CreateProductAsync(CreateProductRequest req) @@ -128,9 +188,8 @@ public class PosDataService public async Task> GetInventoryAsync(Guid? shopId = null) { - AttachToken(); var url = shopId.HasValue ? $"api/bff/inventory?shopId={shopId}" : "api/bff/inventory"; - return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + return await GetListFromApiAsync(url); } // ═══ MEMBERSHIP/CUSTOMER METHODS ═══ @@ -139,7 +198,7 @@ public class PosDataService int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName); public async Task> GetMembersAsync() - { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/members", _jsonOptions) ?? new(); } + => await GetListFromApiAsync("api/bff/members"); // ═══ STAFF CREATE ═══ @@ -182,13 +241,12 @@ public class PosDataService string? EmployeeCode, string? Role, string? Phone); public async Task> GetStaffRolesAsync() - { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/staff/roles", _jsonOptions) ?? new(); } + => await GetListFromApiAsync("api/bff/staff/roles"); public async Task> GetStaffSchedulesAsync(Guid? shopId = null) { - AttachToken(); var url = shopId.HasValue ? $"api/bff/staff/schedules?shopId={shopId}" : "api/bff/staff/schedules"; - return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + return await GetListFromApiAsync(url); } // ═══ ORDERS ═══ @@ -198,11 +256,10 @@ public class PosDataService public async Task> GetOrdersAsync(Guid? shopId = null, string filter = "today") { - AttachToken(); var url = shopId.HasValue ? $"api/bff/orders?shopId={shopId}&filter={filter}" : $"api/bff/orders?filter={filter}"; - return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + return await GetListFromApiAsync(url); } // ═══ WALLETS / FINANCE ═══ @@ -211,17 +268,17 @@ public class PosDataService public record WalletTxnInfo(Guid Id, Guid WalletId, decimal Amount, string? Description, DateTime CreatedAt, string? ItemName); public async Task> GetWalletsAsync() - { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/wallets", _jsonOptions) ?? new(); } + => await GetListFromApiAsync("api/bff/wallets"); public async Task> GetWalletTransactionsAsync(int limit = 50) - { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/wallet/transactions?limit={limit}", _jsonOptions) ?? new(); } + => await GetListFromApiAsync($"api/bff/wallet/transactions?limit={limit}"); // ═══ DEVICES ═══ public record DeviceInfo(Guid Id, string? DeviceToken, string? Platform, bool IsActive, DateTime CreatedAt, string? StaffCode); public async Task> GetDevicesAsync() - { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/devices", _jsonOptions) ?? new(); } + => await GetListFromApiAsync("api/bff/devices"); // ═══ PROMOTIONS ═══ @@ -229,7 +286,7 @@ public class PosDataService bool IsActive, string? DiscountType, decimal? DiscountValue, int VoucherCount, int RedemptionCount); public async Task> GetPromotionsAsync() - { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/promotions", _jsonOptions) ?? new(); } + => await GetListFromApiAsync("api/bff/promotions"); // ═══ CAMPAIGNS CRUD ═══ @@ -240,7 +297,7 @@ public class PosDataService public record CreateCampaignRequest(string Name, string? Description, decimal FaceValue, int TotalVouchers, DateTime StartDate, DateTime EndDate); public async Task> GetCampaignsAsync() - { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/campaigns", _jsonOptions) ?? new(); } + => await GetListFromApiAsync("api/bff/campaigns"); public async Task CreateCampaignAsync(CreateCampaignRequest req) { @@ -297,9 +354,8 @@ public class PosDataService public async Task> GetInventoryTransactionsAsync(Guid? shopId = null) { - AttachToken(); var url = shopId.HasValue ? $"api/bff/inventory/transactions?shopId={shopId}" : "api/bff/inventory/transactions"; - return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + return await GetListFromApiAsync(url); } // ═══ MEMBERSHIP LEVELS ═══ @@ -307,21 +363,21 @@ public class PosDataService public record LevelDefinitionInfo(Guid Id, int Level, string Name, int MinExp, int MaxExp, int MemberCount); public async Task> GetMembershipLevelsAsync() - { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/membership/levels", _jsonOptions) ?? new(); } + => await GetListFromApiAsync("api/bff/membership/levels"); // ═══ SHOP STATS (aggregated per-shop) ═══ public record ShopStatsInfo(Guid ShopId, int ProductCount, int OrderCount, int StaffCount, decimal Revenue); public async Task> GetShopStatsAsync() - { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/shops/stats", _jsonOptions) ?? new(); } + => await GetListFromApiAsync("api/bff/shops/stats"); // ═══ BOOKING RESOURCES ═══ public record ResourceInfo(Guid Id, string Name, string? ResourceType, int Capacity, bool IsActive); public async Task> GetResourcesAsync(Guid shopId) - { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/resources", _jsonOptions) ?? new(); } + => await GetListFromApiAsync($"api/bff/shops/{shopId}/resources"); // ═══ POS DASHBOARD (real-time daily stats) ═══ @@ -351,7 +407,7 @@ public class PosDataService // EN: POS order creation DTOs // VI: DTOs cho tạo đơn POS public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List Items); - public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice); + public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice, string? ProductType = "Physical"); public record CreatePosOrderResponse(Guid OrderId, string TransactionId, decimal TotalAmount, string Status); public async Task CreatePosOrderAsync(CreatePosOrderRequest req) @@ -434,11 +490,10 @@ public class PosDataService public async Task> GetRevenueReportAsync(string period = "daily", Guid? shopId = null) { - AttachToken(); var url = shopId.HasValue ? $"api/bff/reports/revenue?period={period}&shopId={shopId}" : $"api/bff/reports/revenue?period={period}"; - return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + return await GetListFromApiAsync(url); } // ═══ SHOP SETTINGS ═══ @@ -447,10 +502,7 @@ public class PosDataService public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays); public async Task GetShopSettingsAsync(Guid shopId) - { - AttachToken(); - return await _http.GetFromJsonAsync($"api/bff/shops/{shopId}/settings", _jsonOptions); - } + => await GetObjectFromApiAsync($"api/bff/shops/{shopId}/settings"); public async Task UpdateShopSettingsAsync(Guid shopId, UpdateShopSettingsRequest req) { @@ -465,11 +517,10 @@ public class PosDataService public async Task> GetTopProductsAsync(Guid? shopId = null, int limit = 10) { - AttachToken(); var url = shopId.HasValue ? $"api/bff/reports/top-products?shopId={shopId}&limit={limit}" : $"api/bff/reports/top-products?limit={limit}"; - return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + return await GetListFromApiAsync(url); } // ═══ TABLES CRUD ═══ @@ -531,10 +582,9 @@ public class PosDataService public async Task> GetKitchenTicketsAsync(Guid? shopId = null, string status = "pending") { - AttachToken(); if (!shopId.HasValue) return new(); var url = $"api/bff/shops/{shopId}/kitchen-tickets?status={status}"; - return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + return await GetListFromApiAsync(url); } public async Task UpdateTicketStatusAsync(Guid ticketId, UpdateTicketStatusRequest req) @@ -549,10 +599,9 @@ public class PosDataService public async Task> GetRecipesAsync(Guid? shopId = null) { - AttachToken(); if (!shopId.HasValue) return new(); var url = $"api/bff/shops/{shopId}/recipes"; - return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + return await GetListFromApiAsync(url); } public async Task CreateRecipeAsync(CreateRecipeRequest req) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs index 5fbb5d36..024a72be 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs @@ -48,8 +48,12 @@ public class BookingController : ControllerBase /// VI: Hủy lịch hẹn. /// [HttpDelete("appointments/{apptId:guid}/cancel")] - public Task CancelAppointment(Guid apptId) => - _booking.DeleteAsync($"/api/v1/appointments/{apptId}/cancel").ProxyAsync(); + public async Task CancelAppointment(Guid apptId) + { + using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/appointments/{apptId}"); + request.Content = System.Net.Http.Json.JsonContent.Create(new { reason = "Cancelled from POS" }); + return await _booking.SendAsync(request).ProxyAsync(); + } /// /// EN: Get resources for a specific shop. diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs index 5cf8b1d8..1b1393ff 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs @@ -30,8 +30,20 @@ public class OrderController : ControllerBase { var qs = new List(); if (shopId.HasValue) qs.Add($"shopId={shopId}"); - if (!string.IsNullOrEmpty(filter)) qs.Add($"filter={Uri.EscapeDataString(filter)}"); - var query = qs.Count > 0 ? "?" + string.Join("&", qs) : ""; + + // EN: Convert filter shorthand to fromDate/toDate for OrderService + // VI: Chuyển đổi filter shorthand thành fromDate/toDate cho OrderService + var now = DateTime.UtcNow; + var (fromDate, toDate) = filter?.ToLower() switch + { + "week" => (now.Date.AddDays(-(int)now.DayOfWeek), now), + "month" => (new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc), now), + _ => (now.Date, now) // "today" or default + }; + qs.Add($"fromDate={fromDate:O}"); + qs.Add($"toDate={toDate:O}"); + + var query = "?" + string.Join("&", qs); return _order.GetAsync($"/api/v1/orders{query}").ProxyAsync(); } @@ -48,8 +60,13 @@ public class OrderController : ControllerBase /// VI: Hủy đơn hàng. /// [HttpPut("orders/{orderId:guid}/cancel")] - public Task CancelOrder(Guid orderId) => - _order.PutAsync($"/api/v1/orders/{orderId}/cancel", null).ProxyAsync(); + public Task CancelOrder(Guid orderId, [FromQuery] Guid? shopId = null) + { + var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; + return _order.PostAsJsonAsync( + $"/api/v1/orders/{orderId}/cancel{qs}", + new { reason = "Cancelled from POS" }).ProxyAsync(); + } /// /// EN: Create a POS order. diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs index 99b83519..e60d0b4e 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs @@ -5,18 +5,20 @@ using WebClientTpos.Server.Infrastructure; namespace WebClientTpos.Server.Controllers; /// -/// EN: Staff controller — proxies to MerchantService for staff/roles/schedules CRUD. -/// VI: Controller nhân viên — proxy đến MerchantService cho CRUD nhân viên/vai trò/lịch. +/// EN: Staff controller — proxies to MerchantService for staff/roles CRUD and BookingService for schedules. +/// VI: Controller nhân viên — proxy đến MerchantService cho CRUD nhân viên/vai trò và BookingService cho lịch. /// [ApiController] [Route("api/bff")] public class StaffController : ControllerBase { private readonly HttpClient _merchant; + private readonly HttpClient _booking; public StaffController(IHttpClientFactory httpClientFactory) { _merchant = httpClientFactory.CreateClient("MerchantService"); + _booking = httpClientFactory.CreateClient("BookingService"); } /// @@ -57,40 +59,40 @@ public class StaffController : ControllerBase /// [HttpGet("staff/roles")] public Task GetStaffRoles() => - _merchant.GetAsync("/api/v1/merchants/me/staff/roles").ProxyAsync(); + _merchant.GetAsync("/api/v1/staff/roles").ProxyAsync(); /// - /// EN: Get staff schedules — optionally filtered by shopId. - /// VI: Lấy lịch làm việc nhân viên — tùy chọn lọc theo shopId. + /// EN: Get staff schedules — proxies to BookingService. + /// VI: Lấy lịch làm việc nhân viên — proxy đến BookingService. /// [HttpGet("staff/schedules")] public Task GetStaffSchedules([FromQuery] Guid? shopId = null) { var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; - return _merchant.GetAsync($"/api/v1/merchants/me/staff/schedules{qs}").ProxyAsync(); + return _booking.GetAsync($"/api/v1/schedules{qs}").ProxyAsync(); } /// - /// EN: Create a staff schedule. - /// VI: Tạo lịch làm việc nhân viên. + /// EN: Create a staff schedule — proxies to BookingService. + /// VI: Tạo lịch làm việc nhân viên — proxy đến BookingService. /// [HttpPost("staff/schedules")] public Task CreateSchedule([FromBody] JsonElement body) => - _merchant.PostAsJsonAsync("/api/v1/merchants/me/staff/schedules", body).ProxyAsync(); + _booking.PostAsJsonAsync("/api/v1/schedules", body).ProxyAsync(); /// - /// EN: Update a staff schedule. - /// VI: Cập nhật lịch làm việc nhân viên. + /// EN: Update a staff schedule — proxies to BookingService. + /// VI: Cập nhật lịch làm việc nhân viên — proxy đến BookingService. /// [HttpPut("staff/schedules/{scheduleId:guid}")] public Task UpdateSchedule(Guid scheduleId, [FromBody] JsonElement body) => - _merchant.PutAsJsonAsync($"/api/v1/merchants/me/staff/schedules/{scheduleId}", body).ProxyAsync(); + _booking.PutAsJsonAsync($"/api/v1/schedules/{scheduleId}", body).ProxyAsync(); /// - /// EN: Delete a staff schedule. - /// VI: Xóa lịch làm việc nhân viên. + /// EN: Delete a staff schedule — proxies to BookingService. + /// VI: Xóa lịch làm việc nhân viên — proxy đến BookingService. /// [HttpDelete("staff/schedules/{scheduleId:guid}")] public Task DeleteSchedule(Guid scheduleId) => - _merchant.DeleteAsync($"/api/v1/merchants/me/staff/schedules/{scheduleId}").ProxyAsync(); + _booking.DeleteAsync($"/api/v1/schedules/{scheduleId}").ProxyAsync(); } 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 index 998be89c..b1b26b3c 100644 --- a/services/order-service-net/src/OrderService.API/Application/Queries/GetRevenueReportQuery.cs +++ b/services/order-service-net/src/OrderService.API/Application/Queries/GetRevenueReportQuery.cs @@ -26,20 +26,21 @@ public record RevenueReportDto( string Period, Guid ShopId, decimal TotalRevenue, - int TotalOrders, + long TotalOrders, List Data ); /// -/// EN: Revenue data for a single period. -/// VI: Dữ liệu doanh thu cho một kỳ. +/// EN: Revenue data for a single period (class for Dapper compatibility). +/// VI: Dữ liệu doanh thu cho một kỳ (class cho tương thích Dapper). /// -public record RevenuePeriodDto( - DateTime PeriodStart, - decimal Revenue, - int OrderCount, - decimal AvgOrderValue -); +public class RevenuePeriodDto +{ + public DateTime PeriodStart { get; set; } + public decimal Revenue { get; set; } + public long OrderCount { get; set; } + public decimal AvgOrderValue { get; set; } +} /// /// EN: Handler for GetRevenueReportQuery. 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 index 432186f8..a2a1d955 100644 --- a/services/order-service-net/src/OrderService.API/Application/Queries/GetTopProductsQuery.cs +++ b/services/order-service-net/src/OrderService.API/Application/Queries/GetTopProductsQuery.cs @@ -19,16 +19,17 @@ public record GetTopProductsQuery( ) : IRequest>; /// -/// EN: Top product DTO. -/// VI: DTO sản phẩm bán chạy. +/// EN: Top product DTO (class for Dapper compatibility). +/// VI: DTO sản phẩm bán chạy (class cho tương thích Dapper). /// -public record TopProductDto( - Guid ProductId, - string ProductName, - int TotalQuantity, - decimal TotalRevenue, - int OrderCount -); +public class TopProductDto +{ + public Guid ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public long TotalQuantity { get; set; } + public decimal TotalRevenue { get; set; } + public long OrderCount { get; set; } +} /// /// EN: Handler for GetTopProductsQuery.