From dc1ea7c0d29059133ec759ab8c4468b0c0af4c60 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 6 Mar 2026 19:51:37 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20W7-8=20production=20readine?= =?UTF-8?q?ss=20=E2=80=94=20QR=20menu,=20analytics,=20E2E=20tests,=20obser?= =?UTF-8?q?vability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Public QR menu: BFF proxy endpoints (no auth), PosDataService public methods - Revenue analytics + staff performance: Dapper queries, validators, BFF proxy - Playwright E2E tests: 8 spec files covering auth, admin, 5 POS verticals, reports - Observability: Grafana dashboard (HTTP metrics, infra, business), Prometheus alert rules - Fixes: validator frozen-date bug (Must vs LessThanOrEqualTo), PublicMenuController logging + CancellationToken Co-Authored-By: Claude Opus 4.6 --- .../Services/PosDataService.cs | 137 +++++ .../Controllers/PublicMenuController.cs | 219 +++++++ .../Controllers/ReportsController.cs | 40 ++ .../tests/WebClientTpos.E2ETests/.gitignore | 6 + .../helpers/auth.helper.ts | 78 +++ .../helpers/test-data.ts | 100 ++++ .../tests/WebClientTpos.E2ETests/package.json | 22 + .../playwright.config.ts | 44 ++ .../tests/admin-dashboard.spec.ts | 81 +++ .../WebClientTpos.E2ETests/tests/auth.spec.ts | 107 ++++ .../tests/pos-cafe.spec.ts | 95 +++ .../tests/pos-karaoke.spec.ts | 97 ++++ .../tests/pos-restaurant.spec.ts | 96 +++ .../tests/pos-retail.spec.ts | 94 +++ .../tests/pos-spa.spec.ts | 80 +++ .../tests/reports.spec.ts | 84 +++ .../WebClientTpos.E2ETests/tsconfig.json | 16 + .../grafana/dashboards/dashboard-provider.yml | 14 + .../grafana/dashboards/goodgo-overview.json | 549 ++++++++++++++++++ .../observability/prometheus/alert-rules.yml | 165 ++++++ .../Reports/GetRevenueAnalyticsQuery.cs | 250 ++++++++ .../Reports/GetStaffPerformanceQuery.cs | 123 ++++ .../GetRevenueAnalyticsQueryValidator.cs | 41 ++ .../GetStaffPerformanceQueryValidator.cs | 33 ++ .../Controllers/ReportsController.cs | 47 ++ 25 files changed, 2618 insertions(+) create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/PublicMenuController.cs create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/.gitignore create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/helpers/auth.helper.ts create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/helpers/test-data.ts create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/package.json create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/playwright.config.ts create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/admin-dashboard.spec.ts create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/auth.spec.ts create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-cafe.spec.ts create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-karaoke.spec.ts create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-restaurant.spec.ts create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-retail.spec.ts create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-spa.spec.ts create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/reports.spec.ts create mode 100644 apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tsconfig.json create mode 100644 infra/observability/grafana/dashboards/dashboard-provider.yml create mode 100644 infra/observability/grafana/dashboards/goodgo-overview.json create mode 100644 infra/observability/prometheus/alert-rules.yml create mode 100644 services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetRevenueAnalyticsQuery.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetStaffPerformanceQuery.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Validations/GetRevenueAnalyticsQueryValidator.cs create mode 100644 services/order-service-net/src/OrderService.API/Application/Validations/GetStaffPerformanceQueryValidator.cs 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 824b9a4c..a175ef05 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 @@ -1437,6 +1437,84 @@ public class PosDataService return await PostAndGetAsync(url, new { shopId, closeDate = date.ToString("yyyy-MM-dd") }); } + // ═══ ADVANCED REPORTS — REVENUE ANALYTICS & STAFF PERFORMANCE ═══ + + // EN: Revenue analytics DTO — matches backend RevenueAnalyticsDto + // VI: DTO phan tich doanh thu — khop voi RevenueAnalyticsDto backend + public record RevenueAnalyticsInfo( + decimal TotalRevenue, decimal PreviousPeriodRevenue, decimal GrowthPercentage, + int TotalOrders, decimal AverageOrderValue, + List Trends, + List PaymentMethods, + List TopProducts, + List VerticalBreakdown); + + public class RevenueTrendInfo + { + public DateTime Date { get; set; } + public decimal Revenue { get; set; } + public int OrderCount { get; set; } + } + + public class PaymentMethodStatsInfo + { + public string Method { get; set; } = string.Empty; + public decimal Amount { get; set; } + public int Count { get; set; } + public decimal Percentage { get; set; } + } + + public class TopProductAnalyticsInfo + { + public string Name { get; set; } = string.Empty; + public int QuantitySold { get; set; } + public decimal Revenue { get; set; } + } + + public class VerticalRevenueInfo + { + public string Vertical { get; set; } = string.Empty; + public decimal Revenue { get; set; } + public int OrderCount { get; set; } + } + + // EN: Staff performance DTO — matches backend StaffPerformanceDto + // VI: DTO hieu suat nhan vien — khop voi StaffPerformanceDto backend + public record StaffPerformanceInfo(List Staff, StaffMetricsInfo ShopAverage); + + public class StaffMetricsInfo + { + public Guid? StaffId { get; set; } + public string StaffName { get; set; } = string.Empty; + public int OrdersHandled { get; set; } + public decimal TotalRevenue { get; set; } + public decimal AverageOrderValue { get; set; } + public int CompletedOrders { get; set; } + public int CancelledOrders { get; set; } + public double CompletionRate { get; set; } + public double AverageHandlingTimeMinutes { get; set; } + } + + /// + /// EN: Get advanced revenue analytics for a shop over a date range. + /// VI: Lay phan tich doanh thu nang cao cho shop trong khoang thoi gian. + /// + public async Task GetRevenueAnalyticsAsync(Guid shopId, DateTime startDate, DateTime endDate, string period = "daily") + { + var url = $"api/bff/reports/revenue-analytics?shopId={shopId}&startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}&period={Uri.EscapeDataString(period)}"; + return await GetObjectFromApiAsync(url); + } + + /// + /// EN: Get staff performance metrics for a shop over a date range. + /// VI: Lay chi so hieu suat nhan vien cho shop trong khoang thoi gian. + /// + public async Task GetStaffPerformanceAsync(Guid shopId, DateTime startDate, DateTime endDate) + { + var url = $"api/bff/reports/staff-performance?shopId={shopId}&startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}"; + return await GetObjectFromApiAsync(url); + } + // ═══ RETAIL POS — BARCODE LOOKUP, STOCK LEVELS, RETURNS/EXCHANGES ═══ // EN: Product lookup info from catalog-service (barcode/SKU search). @@ -1645,4 +1723,63 @@ public class PosDataService return (await Task.WhenAll(tasks)).ToList(); } + + // ═══ PUBLIC MENU (NO AUTH) ═══ + + // EN: Public DTOs for customer-facing QR menu (no authentication required). + // VI: DTO công khai cho menu QR dành cho khách hàng (không cần xác thực). + public record PublicShopInfo(Guid Id, string Name, string? Address, string? LogoUrl, string? Phone, string? Description); + public record PublicMenuCategory(Guid Id, string Name, string? Description, int DisplayOrder, List Items); + public record PublicMenuItem(Guid Id, string Name, string? Description, decimal Price, string? ImageUrl, bool IsAvailable); + + /// + /// EN: Get public shop info (no auth) — for customer QR menu header. + /// VI: Lấy thông tin shop công khai (không cần auth) — cho header menu QR khách hàng. + /// + public async Task GetPublicShopInfoAsync(Guid shopId) + { + // EN: No AttachToken() — this is a public endpoint. + // VI: Không AttachToken() — đây là endpoint công khai. + var resp = await _http.GetAsync($"api/public/shops/{shopId}"); + 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; + if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object) + return JsonSerializer.Deserialize(data.GetRawText(), _jsonOptions); + if (root.ValueKind == JsonValueKind.Object) + return JsonSerializer.Deserialize(json, _jsonOptions); + return null; + } + + /// + /// EN: Get public menu with categories and items (no auth) — for customer QR menu. + /// VI: Lấy menu công khai với danh mục và sản phẩm (không cần auth) — cho menu QR khách hàng. + /// + public async Task> GetPublicMenuAsync(Guid shopId) + { + // EN: No AttachToken() — this is a public endpoint. + // VI: Không AttachToken() — đây là endpoint công khai. + var resp = await _http.GetAsync($"api/public/shops/{shopId}/menu"); + 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; + + // Handle { "data": [...] } or plain array + if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(data.GetRawText(), _jsonOptions) ?? new(); + if (root.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(json, _jsonOptions) ?? new(); + // Handle { "data": { "items": [...] } } + if (root.TryGetProperty("data", out var dataObj) && dataObj.ValueKind == JsonValueKind.Object + && dataObj.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(items.GetRawText(), _jsonOptions) ?? new(); + + return new(); + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/PublicMenuController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/PublicMenuController.cs new file mode 100644 index 00000000..f3b589db --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/PublicMenuController.cs @@ -0,0 +1,219 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace WebClientTpos.Server.Controllers; + +/// +/// EN: Public menu controller — serves shop info and menu data for customer QR code scanning. +/// These endpoints do NOT require authentication. +/// VI: Controller menu công khai — phục vụ thông tin shop và menu cho khách hàng quét mã QR. +/// Các endpoint này KHÔNG yêu cầu xác thực. +/// +[ApiController] +[Route("api/public")] +public class PublicMenuController : ControllerBase +{ + private readonly HttpClient _merchant; + private readonly HttpClient _catalog; + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly ILogger _logger; + + public PublicMenuController(IHttpClientFactory httpClientFactory, ILogger logger) + { + _merchant = httpClientFactory.CreateClient("MerchantService"); + _catalog = httpClientFactory.CreateClient("CatalogService"); + _logger = logger; + } + + /// + /// EN: Get public shop info (name, address, logo, phone) — no auth required. + /// VI: Lấy thông tin shop công khai (tên, địa chỉ, logo, SĐT) — không cần xác thực. + /// + [HttpGet("shops/{shopId:guid}")] + public async Task GetPublicShopInfo(Guid shopId, CancellationToken cancellationToken) + { + try + { + var resp = await _merchant.GetAsync($"/api/v1/shops/{shopId}", cancellationToken); + if (!resp.IsSuccessStatusCode) + return NotFound(new { success = false, error = new { code = "SHOP_NOT_FOUND", message = "Shop not found / Không tìm thấy cửa hàng" } }); + + var json = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // EN: Extract only public-safe fields from shop data + // VI: Chỉ trích xuất các trường an toàn công khai từ dữ liệu shop + var shopData = root.ValueKind == JsonValueKind.Object && root.TryGetProperty("data", out var data) + ? data : root; + + var publicInfo = new + { + id = shopId, + name = shopData.TryGetProperty("name", out var n) ? n.GetString() : "Shop", + address = shopData.TryGetProperty("address", out var a) ? a.GetString() : null as string, + logoUrl = shopData.TryGetProperty("logoUrl", out var l) ? l.GetString() : + shopData.TryGetProperty("logo", out var l2) ? l2.GetString() : null as string, + phone = shopData.TryGetProperty("phone", out var p) ? p.GetString() : null as string, + description = shopData.TryGetProperty("description", out var d) ? d.GetString() : null as string + }; + + return Ok(new { success = true, data = publicInfo }); + } + catch (Exception ex) + { + _logger.LogError(ex, "EN: Failed to load public shop info for {ShopId} / VI: Không thể tải thông tin shop công khai cho {ShopId}", shopId, shopId); + return StatusCode(500, new { success = false, error = new { code = "INTERNAL_ERROR", message = "Failed to load shop info / Không thể tải thông tin shop" } }); + } + } + + /// + /// EN: Get public menu — categories with products, sorted by display order. No auth required. + /// VI: Lấy menu công khai — danh mục kèm sản phẩm, sắp xếp theo thứ tự hiển thị. Không cần xác thực. + /// + [HttpGet("shops/{shopId:guid}/menu")] + public async Task GetPublicMenu(Guid shopId, CancellationToken cancellationToken) + { + try + { + // EN: Fetch categories and products in parallel + // VI: Lấy danh mục và sản phẩm song song + var categoriesTask = _catalog.GetAsync($"/api/v1/shops/{shopId}/categories", cancellationToken); + var productsTask = _catalog.GetAsync($"/api/v1/shops/{shopId}/products?isActive=true&pageSize=500", cancellationToken); + + await Task.WhenAll(categoriesTask, productsTask); + + var categoriesResp = await categoriesTask; + var productsResp = await productsTask; + + // EN: Parse categories + // VI: Phân tích danh mục + var categories = new List(); + if (categoriesResp.IsSuccessStatusCode) + { + var catJson = await categoriesResp.Content.ReadAsStringAsync(); + categories = ParseList(catJson); + } + + // EN: Parse products + // VI: Phân tích sản phẩm + var products = new List(); + if (productsResp.IsSuccessStatusCode) + { + var prodJson = await productsResp.Content.ReadAsStringAsync(); + products = ParseList(prodJson); + } + + // EN: Group products by category and build menu structure + // VI: Nhóm sản phẩm theo danh mục và xây dựng cấu trúc menu + var productsByCategory = products.GroupBy(p => p.CategoryId).ToDictionary(g => g.Key, g => g.ToList()); + + var menuCategories = categories + .OrderBy(c => c.DisplayOrder) + .Select(c => new + { + id = c.Id, + name = c.Name, + description = c.Description, + displayOrder = c.DisplayOrder, + items = productsByCategory.GetValueOrDefault(c.Id, []) + .Select(p => new + { + id = p.Id, + name = p.Name, + description = p.Description, + price = p.Price, + imageUrl = p.ImageUrl, + isAvailable = p.IsActive + }).ToList() + }) + .Where(c => c.items.Count > 0) + .ToList(); + + // EN: Add an "Uncategorized" group for products without categories + // VI: Thêm nhóm "Chưa phân loại" cho sản phẩm không có danh mục + var uncategorized = products.Where(p => p.CategoryId == Guid.Empty || !categories.Any(c => c.Id == p.CategoryId)).ToList(); + if (uncategorized.Count > 0) + { + menuCategories.Add(new + { + id = Guid.Empty, + name = "Khác", + description = (string?)null, + displayOrder = 9999, + items = uncategorized.Select(p => new + { + id = p.Id, + name = p.Name, + description = p.Description, + price = p.Price, + imageUrl = p.ImageUrl, + isAvailable = p.IsActive + }).ToList() + }); + } + + return Ok(new { success = true, data = menuCategories }); + } + catch (Exception ex) + { + _logger.LogError(ex, "EN: Failed to load public menu for {ShopId} / VI: Không thể tải menu công khai cho {ShopId}", shopId, shopId); + return StatusCode(500, new { success = false, error = new { code = "INTERNAL_ERROR", message = "Failed to load menu / Không thể tải menu" } }); + } + } + + // EN: Parse a list from various API response formats + // VI: Phân tích list từ các định dạng response API khác nhau + private static List ParseList(string json) + { + if (string.IsNullOrWhiteSpace(json)) return []; + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(json, JsonOpts) ?? []; + + if (root.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(items.GetRawText(), JsonOpts) ?? []; + + if (root.TryGetProperty("data", out var data)) + { + if (data.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(data.GetRawText(), JsonOpts) ?? []; + if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("items", out var dataItems) && dataItems.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(dataItems.GetRawText(), JsonOpts) ?? []; + } + + return []; + } + catch { return []; } + } + + private record CategoryDto + { + public Guid Id { get; init; } + public string Name { get; init; } = ""; + public string? Description { get; init; } + public int DisplayOrder { get; init; } + } + + private record ProductDto + { + public Guid Id { get; init; } + public string Name { get; init; } = ""; + public string? Description { get; init; } + public decimal Price { get; init; } + public string? ImageUrl { get; init; } + public bool IsActive { get; init; } = true; + public Guid CategoryId { get; init; } + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs index b0c6d520..5f05dc56 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs @@ -46,6 +46,46 @@ public class ReportsController : ControllerBase return _order.GetAsync($"/api/v1/reports/top-products?{string.Join("&", qs)}").ProxyAsync(); } + /// + /// EN: Get advanced revenue analytics with trends, payment methods, and vertical breakdown. + /// VI: Lay phan tich doanh thu nang cao voi xu huong, phuong thuc thanh toan, va phan tich theo nganh. + /// + [HttpGet("reports/revenue-analytics")] + public Task GetRevenueAnalytics( + [FromQuery] Guid shopId, + [FromQuery] string startDate, + [FromQuery] string endDate, + [FromQuery] string period = "daily") + { + var qs = new List + { + $"shopId={shopId}", + $"startDate={Uri.EscapeDataString(startDate)}", + $"endDate={Uri.EscapeDataString(endDate)}", + $"period={Uri.EscapeDataString(period)}" + }; + return _order.GetAsync($"/api/v1/reports/revenue-analytics?{string.Join("&", qs)}").ProxyAsync(); + } + + /// + /// EN: Get staff performance metrics for a shop. + /// VI: Lay chi so hieu suat nhan vien cho shop. + /// + [HttpGet("reports/staff-performance")] + public Task GetStaffPerformance( + [FromQuery] Guid shopId, + [FromQuery] string startDate, + [FromQuery] string endDate) + { + var qs = new List + { + $"shopId={shopId}", + $"startDate={Uri.EscapeDataString(startDate)}", + $"endDate={Uri.EscapeDataString(endDate)}" + }; + return _order.GetAsync($"/api/v1/reports/staff-performance?{string.Join("&", qs)}").ProxyAsync(); + } + /// /// EN: Get End-of-Day report for a shop. /// VI: Lay bao cao cuoi ngay cho shop. diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/.gitignore b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/.gitignore new file mode 100644 index 00000000..c784ad6e --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +test-results/ +playwright-report/ +blob-report/ +.playwright/ diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/helpers/auth.helper.ts b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/helpers/auth.helper.ts new file mode 100644 index 00000000..42356a02 --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/helpers/auth.helper.ts @@ -0,0 +1,78 @@ +import { type Page, expect } from '@playwright/test'; +import { ADMIN_USER, MERCHANT_USER, ROUTES, BLAZOR_LOAD_TIMEOUT } from './test-data'; + +/** + * EN: Wait for Blazor WASM to finish loading (spinner disappears, interactive DOM ready). + * VI: Doi Blazor WASM tai xong (spinner bien mat, DOM tuong tac san sang). + */ +export async function waitForBlazor(page: Page): Promise { + // Blazor WASM renders a loading placeholder; wait until the app root has content. + // MudBlazor injects a .mud- prefixed element once the framework boots. + await page.waitForLoadState('networkidle', { timeout: BLAZOR_LOAD_TIMEOUT }); + + // Wait for any MudBlazor rendered element, indicating the Blazor runtime is active. + await page.waitForSelector('.mud-layout, .mud-main-content, [class*="mud-"]', { + timeout: BLAZOR_LOAD_TIMEOUT, + state: 'attached', + }); +} + +/** + * EN: Log in as admin (owner/merchant) and wait for redirect to admin dashboard. + * VI: Dang nhap voi vai tro admin (owner/merchant) va doi redirect ve admin dashboard. + */ +export async function loginAsAdmin( + page: Page, + email = ADMIN_USER.email, + password = ADMIN_USER.password, +): Promise { + await page.goto(ROUTES.LOGIN_ADMIN); + await waitForBlazor(page); + + // Fill email + await page.locator('input[type="email"], input[autocomplete="email"]').first().fill(email); + // Fill password + await page.locator('input[type="password"]').first().fill(password); + // Submit + await page.locator('button[type="submit"]').first().click(); + + // Wait for navigation to admin area + await page.waitForURL('**/admin**', { timeout: 15_000 }); + await waitForBlazor(page); +} + +/** + * EN: Log in as merchant and wait for redirect to admin dashboard. + * VI: Dang nhap voi vai tro merchant va doi redirect ve admin dashboard. + */ +export async function loginAsMerchant( + page: Page, + email = MERCHANT_USER.email, + password = MERCHANT_USER.password, +): Promise { + await loginAsAdmin(page, email, password); +} + +/** + * EN: Verify the user is logged in by checking for user-profile or avatar elements. + * VI: Xac nhan nguoi dung da dang nhap bang cach kiem tra phan tu user-profile hoac avatar. + */ +export async function expectLoggedIn(page: Page): Promise { + // The admin layout should render a sidebar or user menu once authenticated. + await expect( + page.locator('.mud-navmenu, .mud-drawer, [class*="sidebar"], [class*="user-menu"]').first(), + ).toBeVisible({ timeout: 10_000 }); +} + +/** + * EN: Log out by navigating to profile and clicking logout, or clearing localStorage token. + * VI: Dang xuat bang cach vao profile va nhan logout, hoac xoa token trong localStorage. + */ +export async function logout(page: Page): Promise { + // Clear the JWT token from localStorage (key: aPOS_token) + await page.evaluate(() => { + localStorage.removeItem('aPOS_token'); + }); + await page.goto(ROUTES.LOGIN); + await waitForBlazor(page); +} diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/helpers/test-data.ts b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/helpers/test-data.ts new file mode 100644 index 00000000..e522dc20 --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/helpers/test-data.ts @@ -0,0 +1,100 @@ +/** + * EN: Shared test constants, credentials, and route definitions. + * VI: Cac hang so test dung chung, thong tin dang nhap, va dinh nghia route. + */ + +// --------------------------------------------------------------------------- +// Credentials +// --------------------------------------------------------------------------- + +export const ADMIN_USER = { + email: 'admin@goodgo.vn', + password: 'Admin@123', +}; + +export const MERCHANT_USER = { + email: 'merchant@goodgo.vn', + password: 'Merchant@123', +}; + +export const STAFF_USER = { + email: 'staff@goodgo.vn', + password: 'Staff@123', +}; + +// --------------------------------------------------------------------------- +// Test Shop / IDs (deterministic UUIDs for seeded data) +// --------------------------------------------------------------------------- + +export const TEST_SHOP_ID = '00000000-0000-0000-0000-000000000001'; +export const TEST_ROOM_ID = '00000000-0000-0000-0000-000000000010'; +export const TEST_TABLE_ID = '00000000-0000-0000-0000-000000000020'; +export const TEST_PRODUCT_ID = '00000000-0000-0000-0000-000000000030'; + +// --------------------------------------------------------------------------- +// Routes — Auth +// --------------------------------------------------------------------------- + +export const ROUTES = { + // Auth + LOGIN: '/auth/login', + LOGIN_ADMIN: '/auth/login/admin', + LOGIN_STAFF: '/auth/login/staff', + LOGIN_CUSTOMER: '/auth/login/customer', + REGISTER: '/register', + FORGOT_PASSWORD: '/forgot-password', + OTP_VERIFY: '/auth/otp-verify', + TWO_FACTOR: '/auth/two-factor', + + // Admin + ADMIN_DASHBOARD: '/admin', + ADMIN_STORES: '/admin/stores', + ADMIN_USERS: '/admin/users', + ADMIN_ROLES: '/admin/roles', + ADMIN_SETTINGS: '/admin/settings', + ADMIN_EOD_REPORT: '/admin/reports/eod', + ADMIN_SPA_THERAPISTS: '/admin/spa/therapists', + ADMIN_SPA_APPOINTMENTS: '/admin/spa/appointments', + + // POS — parameterised with TEST_SHOP_ID + posKaraoke: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/karaoke`, + posKaraokeRoomSelect: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/karaoke/room-select`, + posKaraokeRoomSession: (shopId = TEST_SHOP_ID, roomId = TEST_ROOM_ID) => + `/pos/${shopId}/karaoke/room-session/${roomId}`, + posKaraokeOrderFnb: (shopId = TEST_SHOP_ID, roomId = TEST_ROOM_ID) => + `/pos/${shopId}/karaoke/order-fnb/${roomId}`, + + posRestaurant: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/restaurant`, + posRestaurantTableMap: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/restaurant/table-map`, + posRestaurantTableDetail: (shopId = TEST_SHOP_ID, tableId = TEST_TABLE_ID) => + `/pos/${shopId}/restaurant/table-detail/${tableId}`, + posRestaurantKitchenDisplay: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/restaurant/kitchen-display`, + posRestaurantEodReport: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/restaurant/eod-report`, + + posCafe: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/cafe`, + posCafeBaristaQueue: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/cafe/barista-queue`, + posCafeLoyaltyStamp: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/cafe/loyalty-stamp`, + posCafeDailyReport: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/cafe/daily-report`, + + posRetail: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/retail`, + posRetailProductSearch: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/retail/product-search`, + posRetailStockCheck: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/retail/stock-check`, + posRetailReturnExchange: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/retail/return-exchange`, + + posSpa: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/spa`, + posSpaAppointmentBook: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/spa/appointment-book`, + posSpaStaffAssign: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/spa/staff-assign`, + posSpaJourney: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/spa/spa-journey`, + + // Shared POS + posPaymentMethodSelect: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/payment/method-select`, + posPaymentCash: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/payment/cash`, + posPaymentSuccess: (shopId = TEST_SHOP_ID) => `/pos/${shopId}/payment/success`, +} as const; + +// --------------------------------------------------------------------------- +// Blazor WASM loading helpers +// --------------------------------------------------------------------------- + +/** Maximum time (ms) to wait for the Blazor WASM runtime to finish loading. */ +export const BLAZOR_LOAD_TIMEOUT = 30_000; diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/package.json b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/package.json new file mode 100644 index 00000000..8004e083 --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/package.json @@ -0,0 +1,22 @@ +{ + "name": "webclienttpos-e2e", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "npx playwright test", + "test:headed": "npx playwright test --headed", + "test:ui": "npx playwright test --ui", + "test:auth": "npx playwright test tests/auth.spec.ts", + "test:admin": "npx playwright test tests/admin-dashboard.spec.ts", + "test:karaoke": "npx playwright test tests/pos-karaoke.spec.ts", + "test:restaurant": "npx playwright test tests/pos-restaurant.spec.ts", + "test:cafe": "npx playwright test tests/pos-cafe.spec.ts", + "test:retail": "npx playwright test tests/pos-retail.spec.ts", + "test:spa": "npx playwright test tests/pos-spa.spec.ts", + "test:reports": "npx playwright test tests/reports.spec.ts", + "report": "npx playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.48.0" + } +} diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/playwright.config.ts b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/playwright.config.ts new file mode 100644 index 00000000..569067a2 --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from '@playwright/test'; + +/** + * EN: Playwright E2E test configuration for GoodGo TPOS Blazor WASM app. + * VI: Cau hinh Playwright E2E test cho ung dung Blazor WASM GoodGo TPOS. + * + * The Blazor WASM app is served by the .NET Server project (BFF with YARP). + * Default local URL: http://localhost:5092 + */ +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 1, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { open: 'never' }], + ['list'], + ], + timeout: 60_000, + + use: { + baseURL: process.env.BASE_URL || 'http://localhost:5092', + screenshot: 'only-on-failure', + trace: 'on-first-retry', + actionTimeout: 15_000, + navigationTimeout: 30_000, + }, + + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], + + /* Optionally start the Blazor Server project before tests */ + // webServer: { + // command: 'dotnet run --project ../../src/WebClientTpos.Server', + // url: 'http://localhost:5092/health', + // reuseExistingServer: !process.env.CI, + // timeout: 120_000, + // }, +}); diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/admin-dashboard.spec.ts b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/admin-dashboard.spec.ts new file mode 100644 index 00000000..c1d49100 --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/admin-dashboard.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from '@playwright/test'; +import { loginAsAdmin, waitForBlazor } from '../helpers/auth.helper'; +import { ROUTES } from '../helpers/test-data'; + +/** + * EN: Admin dashboard and navigation E2E tests. + * VI: E2E tests cho admin dashboard va dieu huong. + */ +test.describe('Admin Dashboard', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + // ----------------------------------------------------------------------- + // 1. Dashboard loads after login + // ----------------------------------------------------------------------- + test('should display admin dashboard after login', async ({ page }) => { + await expect(page).toHaveURL(/\/admin/); + + // Dashboard should show main content area with MudBlazor layout + const mainContent = page.locator('.mud-main-content, .mud-layout-content, [class*="dashboard"]').first(); + await expect(mainContent).toBeVisible({ timeout: 10_000 }); + }); + + // ----------------------------------------------------------------------- + // 2. Sidebar navigation is visible + // ----------------------------------------------------------------------- + test('should show sidebar navigation', async ({ page }) => { + // Admin layout has a 2-level sidebar (MudDrawer / MudNavMenu) + const sidebar = page.locator('.mud-drawer, .mud-navmenu, [class*="sidebar"]').first(); + await expect(sidebar).toBeVisible({ timeout: 10_000 }); + + // Sidebar should contain navigation links + const navLinks = page.locator('.mud-nav-link, .mud-list-item, [class*="nav-link"]'); + const count = await navLinks.count(); + expect(count).toBeGreaterThan(0); + }); + + // ----------------------------------------------------------------------- + // 3. Navigate to shop / store management + // ----------------------------------------------------------------------- + test('should navigate to store management', async ({ page }) => { + await page.goto(ROUTES.ADMIN_STORES); + await waitForBlazor(page); + + await expect(page).toHaveURL(/\/admin\/stores/); + + // Should display a table or list of stores/shops + const content = page.locator( + '.mud-table, .mud-data-grid, [class*="store"], [class*="shop"]', + ).first(); + await expect(content).toBeVisible({ timeout: 10_000 }); + }); + + // ----------------------------------------------------------------------- + // 4. Navigate to reports section + // ----------------------------------------------------------------------- + test('should navigate to EOD reports page', async ({ page }) => { + await page.goto(ROUTES.ADMIN_EOD_REPORT); + await waitForBlazor(page); + + await expect(page).toHaveURL(/\/admin\/reports\/eod/); + + // Reports page should have content + const content = page.locator('.mud-main-content, .mud-layout-content, [class*="report"]').first(); + await expect(content).toBeVisible({ timeout: 10_000 }); + }); + + // ----------------------------------------------------------------------- + // 5. User profile / settings accessible + // ----------------------------------------------------------------------- + test('should navigate to admin settings', async ({ page }) => { + await page.goto(ROUTES.ADMIN_SETTINGS); + await waitForBlazor(page); + + await expect(page).toHaveURL(/\/admin\/settings/); + + const content = page.locator('.mud-main-content, .mud-layout-content, [class*="settings"]').first(); + await expect(content).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/auth.spec.ts b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/auth.spec.ts new file mode 100644 index 00000000..4cc69f29 --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/auth.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from '@playwright/test'; +import { waitForBlazor, loginAsAdmin, logout } from '../helpers/auth.helper'; +import { ROUTES, ADMIN_USER } from '../helpers/test-data'; + +/** + * EN: Authentication flow E2E tests for GoodGo TPOS. + * VI: E2E tests cho luong xac thuc GoodGo TPOS. + */ +test.describe('Authentication', () => { + test.beforeEach(async ({ page }) => { + // Ensure clean state — no leftover tokens + await page.goto(ROUTES.LOGIN); + await page.evaluate(() => localStorage.clear()); + }); + + // ----------------------------------------------------------------------- + // 1. Login page renders correctly + // ----------------------------------------------------------------------- + test('should display login page with form elements', async ({ page }) => { + await page.goto(ROUTES.LOGIN); + await waitForBlazor(page); + + // Login page should render role selection or login form + await expect(page).toHaveURL(/\/(auth\/login|login)/); + + // Should have at least one visible heading or brand element + const heading = page.locator('h1, h2, h3, [class*="brand"], [class*="title"]').first(); + await expect(heading).toBeVisible({ timeout: 10_000 }); + }); + + // ----------------------------------------------------------------------- + // 2. Admin login page shows email and password fields + // ----------------------------------------------------------------------- + test('should show email and password inputs on admin login', async ({ page }) => { + await page.goto(ROUTES.LOGIN_ADMIN); + await waitForBlazor(page); + + const emailInput = page.locator('input[type="email"], input[autocomplete="email"]').first(); + const passwordInput = page.locator('input[type="password"]').first(); + + await expect(emailInput).toBeVisible(); + await expect(passwordInput).toBeVisible(); + }); + + // ----------------------------------------------------------------------- + // 3. Validation errors for empty form submission + // ----------------------------------------------------------------------- + test('should show validation errors for empty form submission', async ({ page }) => { + await page.goto(ROUTES.LOGIN_ADMIN); + await waitForBlazor(page); + + // Click submit without filling in fields + const submitButton = page.locator('button[type="submit"]').first(); + await submitButton.click(); + + // MudBlazor validation renders .mud-input-error or helper text with error class + const errorMessage = page.locator( + '.mud-input-error, .mud-input-helper-text.mud-input-error, [class*="validation-message"], [class*="error"]', + ).first(); + await expect(errorMessage).toBeVisible({ timeout: 5_000 }); + }); + + // ----------------------------------------------------------------------- + // 4. Error message for wrong credentials + // ----------------------------------------------------------------------- + test('should show error for wrong credentials', async ({ page }) => { + await page.goto(ROUTES.LOGIN_ADMIN); + await waitForBlazor(page); + + await page.locator('input[type="email"], input[autocomplete="email"]').first().fill('wrong@goodgo.vn'); + await page.locator('input[type="password"]').first().fill('WrongPassword@999'); + await page.locator('button[type="submit"]').first().click(); + + // Should show an error notification (MudSnackbar) or inline error + const errorIndicator = page.locator( + '.mud-snackbar-error, .mud-alert-error, [class*="error"], [class*="snackbar"]', + ).first(); + await expect(errorIndicator).toBeVisible({ timeout: 10_000 }); + }); + + // ----------------------------------------------------------------------- + // 5. Successful login redirects to admin dashboard + // ----------------------------------------------------------------------- + test('should login successfully and redirect to admin dashboard', async ({ page }) => { + await loginAsAdmin(page); + + await expect(page).toHaveURL(/\/admin/); + + // Should store JWT token in localStorage + const token = await page.evaluate(() => localStorage.getItem('aPOS_token')); + expect(token).toBeTruthy(); + }); + + // ----------------------------------------------------------------------- + // 6. Register page renders + // ----------------------------------------------------------------------- + test('should display register page', async ({ page }) => { + await page.goto(ROUTES.REGISTER); + await waitForBlazor(page); + + await expect(page).toHaveURL(/\/register/); + + // Should have registration form elements + const formElement = page.locator('form, [class*="register"], [class*="auth"]').first(); + await expect(formElement).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-cafe.spec.ts b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-cafe.spec.ts new file mode 100644 index 00000000..ec28ff41 --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-cafe.spec.ts @@ -0,0 +1,95 @@ +import { test, expect } from '@playwright/test'; +import { loginAsAdmin, waitForBlazor } from '../helpers/auth.helper'; +import { ROUTES, TEST_SHOP_ID } from '../helpers/test-data'; + +/** + * EN: Cafe POS workflow E2E tests. + * VI: E2E tests cho luong POS Cafe. + * + * Routes: + * /pos/{ShopId}/cafe — CafeDesktop + * /pos/{ShopId}/cafe/barista-queue — Barista queue management + * /pos/{ShopId}/cafe/loyalty-stamp — Customer stamp/loyalty card + * /pos/{ShopId}/cafe/daily-report — Daily sales report + * /pos/{ShopId}/cafe/queue-display — Customer-facing queue display + */ +test.describe('POS — Cafe', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + // ----------------------------------------------------------------------- + // 1. Cafe POS page loads + // ----------------------------------------------------------------------- + test('should load cafe POS page', async ({ page }) => { + await page.goto(ROUTES.posCafe()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/pos/${TEST_SHOP_ID}/cafe`)); + + const content = page.locator( + '.mud-main-content, .mud-layout-content, [class*="cafe"], [class*="pos"]', + ).first(); + await expect(content).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 2. Barista queue page + // ----------------------------------------------------------------------- + test('should display barista queue', async ({ page }) => { + await page.goto(ROUTES.posCafeBaristaQueue()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/cafe/barista-queue`)); + + // Queue should show list or cards of drink orders + const queueContent = page.locator( + '.mud-card, .mud-list, .mud-grid, [class*="queue"], [class*="barista"]', + ).first(); + await expect(queueContent).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 3. Add drink to order (cafe desktop has menu + cart) + // ----------------------------------------------------------------------- + test('should display menu categories and items on cafe desktop', async ({ page }) => { + await page.goto(ROUTES.posCafe()); + await waitForBlazor(page); + + // Cafe desktop has menu categories (tabs or chips) and product cards + const menuArea = page.locator( + '.mud-tabs, .mud-chip-set, .mud-card, [class*="menu"], [class*="category"]', + ).first(); + await expect(menuArea).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 4. Queue display page loads (customer-facing) + // ----------------------------------------------------------------------- + test('should load queue display page', async ({ page }) => { + await page.goto(`/pos/${TEST_SHOP_ID}/cafe/queue-display`); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/cafe/queue-display`)); + + const display = page.locator( + '.mud-main-content, .mud-layout-content, [class*="queue"], [class*="display"]', + ).first(); + await expect(display).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 5. Loyalty stamp card page + // ----------------------------------------------------------------------- + test('should display loyalty stamp card page', async ({ page }) => { + await page.goto(ROUTES.posCafeLoyaltyStamp()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/cafe/loyalty-stamp`)); + + const stampContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="stamp"], [class*="loyalty"]', + ).first(); + await expect(stampContent).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-karaoke.spec.ts b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-karaoke.spec.ts new file mode 100644 index 00000000..12602d93 --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-karaoke.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from '@playwright/test'; +import { loginAsAdmin, waitForBlazor } from '../helpers/auth.helper'; +import { ROUTES, TEST_SHOP_ID, TEST_ROOM_ID } from '../helpers/test-data'; + +/** + * EN: Karaoke POS workflow E2E tests. + * VI: E2E tests cho luong POS Karaoke. + * + * Routes: + * /pos/{ShopId}/karaoke — KaraokeDesktop (main) + * /pos/{ShopId}/karaoke/room-select — Room selection + * /pos/{ShopId}/karaoke/room-session/{RoomId} — Active session + * /pos/{ShopId}/karaoke/order-fnb/{RoomId} — F&B ordering + * /pos/{ShopId}/karaoke/room-map — Room map overview + */ +test.describe('POS — Karaoke', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + // ----------------------------------------------------------------------- + // 1. Karaoke POS page loads + // ----------------------------------------------------------------------- + test('should load karaoke POS page', async ({ page }) => { + await page.goto(ROUTES.posKaraoke()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/pos/${TEST_SHOP_ID}/karaoke`)); + + // Main karaoke page should render content + const content = page.locator( + '.mud-main-content, .mud-layout-content, [class*="karaoke"], [class*="pos"]', + ).first(); + await expect(content).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 2. Room list with status indicators + // ----------------------------------------------------------------------- + test('should display room list with status indicators', async ({ page }) => { + await page.goto(ROUTES.posKaraokeRoomSelect()); + await waitForBlazor(page); + + // Room select page should show rooms (cards, grid, or list) + const roomContainer = page.locator( + '.mud-card, .mud-grid, .mud-paper, [class*="room"]', + ).first(); + await expect(roomContainer).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 3. Open a room session + // ----------------------------------------------------------------------- + test('should open a room session page', async ({ page }) => { + await page.goto(ROUTES.posKaraokeRoomSession()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/karaoke/room-session/${TEST_ROOM_ID}`)); + + // Session page should render timer or session info + const sessionContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="session"], [class*="room"]', + ).first(); + await expect(sessionContent).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 4. Add F&B items to room order + // ----------------------------------------------------------------------- + test('should load F&B ordering page for a room', async ({ page }) => { + await page.goto(ROUTES.posKaraokeOrderFnb()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/karaoke/order-fnb/${TEST_ROOM_ID}`)); + + // F&B page should display menu items or categories + const menuContent = page.locator( + '.mud-card, .mud-grid, .mud-list, [class*="menu"], [class*="fnb"], [class*="order"]', + ).first(); + await expect(menuContent).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 5. Room map overview + // ----------------------------------------------------------------------- + test('should display room map overview', async ({ page }) => { + await page.goto(ROUTES.posKaraoke() + '/room-map'); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/karaoke/room-map`)); + + const mapContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="map"], [class*="room"]', + ).first(); + await expect(mapContent).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-restaurant.spec.ts b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-restaurant.spec.ts new file mode 100644 index 00000000..72c4244b --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-restaurant.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from '@playwright/test'; +import { loginAsAdmin, waitForBlazor } from '../helpers/auth.helper'; +import { ROUTES, TEST_SHOP_ID, TEST_TABLE_ID } from '../helpers/test-data'; + +/** + * EN: Restaurant POS workflow E2E tests. + * VI: E2E tests cho luong POS Nha hang. + * + * Routes: + * /pos/{ShopId}/restaurant — RestaurantDesktop + * /pos/{ShopId}/restaurant/table-map — Table layout map + * /pos/{ShopId}/restaurant/table-detail/{TableId} — Table detail / order + * /pos/{ShopId}/restaurant/kitchen-display — KDS (Kitchen Display System) + * /pos/{ShopId}/restaurant/eod-report — End-of-Day report + */ +test.describe('POS — Restaurant', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + // ----------------------------------------------------------------------- + // 1. Restaurant POS page loads + // ----------------------------------------------------------------------- + test('should load restaurant POS page', async ({ page }) => { + await page.goto(ROUTES.posRestaurant()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/pos/${TEST_SHOP_ID}/restaurant`)); + + const content = page.locator( + '.mud-main-content, .mud-layout-content, [class*="restaurant"], [class*="pos"]', + ).first(); + await expect(content).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 2. Table layout map displays + // ----------------------------------------------------------------------- + test('should display table layout map', async ({ page }) => { + await page.goto(ROUTES.posRestaurantTableMap()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/restaurant/table-map`)); + + // Table map renders tables as cards, grid items, or custom elements + const tableContainer = page.locator( + '.mud-card, .mud-grid, .mud-paper, [class*="table"], [class*="map"]', + ).first(); + await expect(tableContainer).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 3. Table detail / create order from table + // ----------------------------------------------------------------------- + test('should load table detail page for creating orders', async ({ page }) => { + await page.goto(ROUTES.posRestaurantTableDetail()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/restaurant/table-detail/${TEST_TABLE_ID}`)); + + const detail = page.locator( + '.mud-main-content, .mud-layout-content, [class*="table"], [class*="order"], [class*="detail"]', + ).first(); + await expect(detail).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 4. Kitchen Display System (KDS) + // ----------------------------------------------------------------------- + test('should load kitchen display system', async ({ page }) => { + await page.goto(ROUTES.posRestaurantKitchenDisplay()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/restaurant/kitchen-display`)); + + const kdsContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="kitchen"], [class*="kds"], [class*="display"]', + ).first(); + await expect(kdsContent).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 5. End-of-Day report + // ----------------------------------------------------------------------- + test('should load restaurant EOD report', async ({ page }) => { + await page.goto(ROUTES.posRestaurantEodReport()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/restaurant/eod-report`)); + + const reportContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="report"], [class*="eod"]', + ).first(); + await expect(reportContent).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-retail.spec.ts b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-retail.spec.ts new file mode 100644 index 00000000..0fe33174 --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-retail.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test'; +import { loginAsAdmin, waitForBlazor } from '../helpers/auth.helper'; +import { ROUTES, TEST_SHOP_ID } from '../helpers/test-data'; + +/** + * EN: Retail POS workflow E2E tests. + * VI: E2E tests cho luong POS Ban le. + * + * Routes: + * /pos/{ShopId}/retail — RetailDesktop + * /pos/{ShopId}/retail/product-search — Barcode scan / product search + * /pos/{ShopId}/retail/stock-check — Stock level check + * /pos/{ShopId}/retail/return-exchange — Return/exchange dialog + */ +test.describe('POS — Retail', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + // ----------------------------------------------------------------------- + // 1. Retail POS page loads + // ----------------------------------------------------------------------- + test('should load retail POS page', async ({ page }) => { + await page.goto(ROUTES.posRetail()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/pos/${TEST_SHOP_ID}/retail`)); + + const content = page.locator( + '.mud-main-content, .mud-layout-content, [class*="retail"], [class*="pos"]', + ).first(); + await expect(content).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 2. Product search / barcode scan page + // ----------------------------------------------------------------------- + test('should load product search page with search input', async ({ page }) => { + await page.goto(ROUTES.posRetailProductSearch()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/retail/product-search`)); + + // Product search should have a text input for barcode/name search + const searchInput = page.locator( + 'input[type="text"], input[type="search"], .mud-input input, [class*="search"]', + ).first(); + await expect(searchInput).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 3. Retail desktop shows cart area + // ----------------------------------------------------------------------- + test('should display cart area on retail desktop', async ({ page }) => { + await page.goto(ROUTES.posRetail()); + await waitForBlazor(page); + + // Retail POS typically has a product grid + cart panel + const cartArea = page.locator( + '.mud-paper, .mud-card, [class*="cart"], [class*="order"], [class*="checkout"]', + ).first(); + await expect(cartArea).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 4. Stock check page + // ----------------------------------------------------------------------- + test('should load stock check page', async ({ page }) => { + await page.goto(ROUTES.posRetailStockCheck()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/retail/stock-check`)); + + const stockContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="stock"], [class*="inventory"]', + ).first(); + await expect(stockContent).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 5. Return/exchange dialog page + // ----------------------------------------------------------------------- + test('should load return and exchange page', async ({ page }) => { + await page.goto(ROUTES.posRetailReturnExchange()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/retail/return-exchange`)); + + const returnContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="return"], [class*="exchange"]', + ).first(); + await expect(returnContent).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-spa.spec.ts b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-spa.spec.ts new file mode 100644 index 00000000..a7e40db3 --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/pos-spa.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test'; +import { loginAsAdmin, waitForBlazor } from '../helpers/auth.helper'; +import { ROUTES, TEST_SHOP_ID } from '../helpers/test-data'; + +/** + * EN: Spa POS workflow E2E tests. + * VI: E2E tests cho luong POS Spa. + * + * Routes: + * /pos/{ShopId}/spa — SpaDesktop + * /pos/{ShopId}/spa/appointment-book — Appointment booking + * /pos/{ShopId}/spa/staff-assign — Therapist assignment + * /pos/{ShopId}/spa/spa-journey — Service journey tracker + */ +test.describe('POS — Spa', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + // ----------------------------------------------------------------------- + // 1. Spa POS page loads + // ----------------------------------------------------------------------- + test('should load spa management page', async ({ page }) => { + await page.goto(ROUTES.posSpa()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/pos/${TEST_SHOP_ID}/spa`)); + + const content = page.locator( + '.mud-main-content, .mud-layout-content, [class*="spa"], [class*="pos"]', + ).first(); + await expect(content).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 2. Staff / therapist assignment page + // ----------------------------------------------------------------------- + test('should display therapist / staff assignment page', async ({ page }) => { + await page.goto(ROUTES.posSpaStaffAssign()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/spa/staff-assign`)); + + const staffContent = page.locator( + '.mud-card, .mud-table, .mud-list, [class*="therapist"], [class*="staff"]', + ).first(); + await expect(staffContent).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 3. Appointment booking page + // ----------------------------------------------------------------------- + test('should load appointment booking page', async ({ page }) => { + await page.goto(ROUTES.posSpaAppointmentBook()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/spa/appointment-book`)); + + // Appointment page should show a calendar, form, or time slot picker + const appointmentContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="appointment"], [class*="calendar"], [class*="booking"]', + ).first(); + await expect(appointmentContent).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 4. Spa journey tracker page + // ----------------------------------------------------------------------- + test('should load spa service journey page', async ({ page }) => { + await page.goto(ROUTES.posSpaJourney()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/spa/spa-journey`)); + + const journeyContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="journey"], [class*="spa"]', + ).first(); + await expect(journeyContent).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/reports.spec.ts b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/reports.spec.ts new file mode 100644 index 00000000..c952ce3d --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tests/reports.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from '@playwright/test'; +import { loginAsAdmin, waitForBlazor } from '../helpers/auth.helper'; +import { ROUTES, TEST_SHOP_ID } from '../helpers/test-data'; + +/** + * EN: Reports and analytics E2E tests. + * VI: E2E tests cho bao cao va phan tich. + * + * Routes: + * /admin/reports/eod — Admin EOD report + * /pos/{ShopId}/restaurant/eod-report — Restaurant EOD + * /pos/{ShopId}/cafe/daily-report — Cafe daily report + * /marketing/analytics — Marketing analytics + */ +test.describe('Reports', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + // ----------------------------------------------------------------------- + // 1. Admin EOD report page loads + // ----------------------------------------------------------------------- + test('should load admin EOD report page', async ({ page }) => { + await page.goto(ROUTES.ADMIN_EOD_REPORT); + await waitForBlazor(page); + + await expect(page).toHaveURL(/\/admin\/reports\/eod/); + + const reportContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="report"], [class*="eod"]', + ).first(); + await expect(reportContent).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 2. Revenue / chart elements present + // ----------------------------------------------------------------------- + test('should display revenue or chart elements on report page', async ({ page }) => { + await page.goto(ROUTES.ADMIN_EOD_REPORT); + await waitForBlazor(page); + + // Report pages typically contain charts (MudChart), data tables, or summary cards + const dataElement = page.locator( + '.mud-chart, .mud-table, .mud-card, canvas, svg[class*="chart"], [class*="revenue"], [class*="summary"]', + ).first(); + await expect(dataElement).toBeVisible({ timeout: 15_000 }); + }); + + // ----------------------------------------------------------------------- + // 3. Cafe daily report loads with date context + // ----------------------------------------------------------------------- + test('should load cafe daily report page', async ({ page }) => { + await page.goto(ROUTES.posCafeDailyReport()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/cafe/daily-report`)); + + const reportContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="report"], [class*="daily"]', + ).first(); + await expect(reportContent).toBeVisible({ timeout: 15_000 }); + + // Date picker or filter should be present + const dateElement = page.locator( + '.mud-picker, .mud-date-picker, input[type="date"], [class*="date"], [class*="filter"]', + ).first(); + await expect(dateElement).toBeVisible({ timeout: 10_000 }); + }); + + // ----------------------------------------------------------------------- + // 4. Restaurant EOD report with close-day context + // ----------------------------------------------------------------------- + test('should load restaurant EOD report page', async ({ page }) => { + await page.goto(ROUTES.posRestaurantEodReport()); + await waitForBlazor(page); + + await expect(page).toHaveURL(new RegExp(`/restaurant/eod-report`)); + + const reportContent = page.locator( + '.mud-main-content, .mud-layout-content, [class*="report"], [class*="eod"]', + ).first(); + await expect(reportContent).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tsconfig.json b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tsconfig.json new file mode 100644 index 00000000..50a6d204 --- /dev/null +++ b/apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["tests/**/*.ts", "helpers/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/infra/observability/grafana/dashboards/dashboard-provider.yml b/infra/observability/grafana/dashboards/dashboard-provider.yml new file mode 100644 index 00000000..f210bff8 --- /dev/null +++ b/infra/observability/grafana/dashboards/dashboard-provider.yml @@ -0,0 +1,14 @@ +apiVersion: 1 + +providers: + - name: 'GoodGo Dashboards' + orgId: 1 + folder: 'GoodGo' + type: file + disableDeletion: false + editable: true + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards + foldersFromFilesStructure: false diff --git a/infra/observability/grafana/dashboards/goodgo-overview.json b/infra/observability/grafana/dashboards/goodgo-overview.json new file mode 100644 index 00000000..8e661c5b --- /dev/null +++ b/infra/observability/grafana/dashboards/goodgo-overview.json @@ -0,0 +1,549 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "Service Health", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [ + { "options": { "0": { "color": "red", "index": 1, "text": "DOWN" }, "1": { "color": "green", "index": 0, "text": "UP" } }, "type": "value" } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "green", "value": 1 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 24, "x": 0, "y": 1 }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "up{job=\"iam-service\"}", + "legendFormat": "IAM Service", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "up{job=\"merchant-service\"}", + "legendFormat": "Merchant Service", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "up{job=\"order-service\"}", + "legendFormat": "Order Service", + "refId": "C" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "up{job=\"fnb-engine\"}", + "legendFormat": "FnB Engine", + "refId": "D" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "up{job=\"wallet-service\"}", + "legendFormat": "Wallet Service", + "refId": "E" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "up{job=\"catalog-service\"}", + "legendFormat": "Catalog Service", + "refId": "F" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "up{job=\"inventory-service\"}", + "legendFormat": "Inventory Service", + "refId": "G" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "up{job=\"chat-service\"}", + "legendFormat": "Chat Service", + "refId": "H" + } + ], + "title": "Service Health Status", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 101, + "title": "HTTP Metrics", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "req/s", + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "opacity", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" } + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 2, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_requests_received_total[5m])) by (job)", + "legendFormat": "{{ job }}", + "refId": "A" + } + ], + "title": "Request Rate (req/s per service)", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "seconds", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "opacity", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 3, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))", + "legendFormat": "p50 {{ job }}", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))", + "legendFormat": "p95 {{ job }}", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))", + "legendFormat": "p99 {{ job }}", + "refId": "C" + } + ], + "title": "Response Time (p50 / p95 / p99)", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "errors/s", + "drawStyle": "bars", + "fillOpacity": 50, + "gradientMode": "scheme", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "normal" } + }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": "4xx.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": "5xx.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, + "id": 4, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_requests_received_total{code=~\"4..\"}[5m])) by (job)", + "legendFormat": "4xx {{ job }}", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_requests_received_total{code=~\"5..\"}[5m])) by (job)", + "legendFormat": "5xx {{ job }}", + "refId": "B" + } + ], + "title": "Error Rate (4xx / 5xx per service)", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "red", "value": 100 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "id": 5, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "signalr_connections_current", + "legendFormat": "{{ job }} - {{ hub }}", + "refId": "A" + } + ], + "title": "Active SignalR Connections", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 }, + "id": 102, + "title": "Infrastructure", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "connections", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "opacity", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" } + }, + "unit": "short", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 80 }, + { "color": "red", "value": 95 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 23 }, + "id": 6, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "dotnet_npgsql_idle_connections", + "legendFormat": "idle {{ job }}", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "dotnet_npgsql_busy_connections", + "legendFormat": "busy {{ job }}", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "dotnet_npgsql_max_pool_size", + "legendFormat": "max {{ job }}", + "refId": "C" + } + ], + "title": "PostgreSQL Connection Pool Usage", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "opacity", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" } + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 23 }, + "id": 7, + "options": { + "legend": { "calcs": ["mean", "lastNotNull"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total)", + "legendFormat": "Cache Hit Ratio", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "redis_memory_used_bytes / redis_memory_max_bytes", + "legendFormat": "Memory Usage %", + "refId": "B" + } + ], + "title": "Redis Cache Hit Ratio & Memory", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 31 }, + "id": 103, + "title": "Business Metrics", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "orders/h", + "drawStyle": "bars", + "fillOpacity": 60, + "gradientMode": "scheme", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "normal" } + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Created" }, + "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "Completed" }, + "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "Cancelled" }, + "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 32 }, + "id": 8, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "increase(goodgo_orders_created_total[1h])", + "legendFormat": "Created", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "increase(goodgo_orders_completed_total[1h])", + "legendFormat": "Completed", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "increase(goodgo_orders_cancelled_total[1h])", + "legendFormat": "Cancelled", + "refId": "C" + } + ], + "title": "Orders per Hour (Created / Completed / Cancelled)", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 536870912 }, + { "color": "red", "value": 1073741824 } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { "h": 6, "w": 24, "x": 0, "y": 40 }, + "id": 9, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "dotnet_gc_memory_total_available_bytes - dotnet_gc_heap_size_bytes", + "legendFormat": "Available {{ job }}", + "refId": "A" + } + ], + "title": ".NET Memory (Available per service)", + "type": "stat" + } + ], + "schemaVersion": 39, + "tags": ["goodgo", "microservices", "overview"], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "Prometheus", "value": "Prometheus" }, + "hide": 0, + "includeAll": false, + "label": "Datasource", + "multi": false, + "name": "DS_PROMETHEUS", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + } + ] + }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "GoodGo Platform Overview", + "uid": "goodgo-overview", + "version": 1 +} diff --git a/infra/observability/prometheus/alert-rules.yml b/infra/observability/prometheus/alert-rules.yml new file mode 100644 index 00000000..38c6a945 --- /dev/null +++ b/infra/observability/prometheus/alert-rules.yml @@ -0,0 +1,165 @@ +groups: + # ========================================================================= + # GoodGo Platform - Prometheus Alert Rules + # ========================================================================= + # EN: Critical alerts for service health, performance, and infrastructure. + # VI: Canh bao nghiem trong cho suc khoe dich vu, hieu nang, va ha tang. + # ========================================================================= + + - name: service_health + interval: 30s + rules: + # ------------------------------------------------------------------- + # Service Down — healthcheck fails for > 1 minute + # ------------------------------------------------------------------- + - alert: ServiceDown + expr: up == 0 + for: 1m + labels: + severity: critical + team: platform + annotations: + summary: "Service {{ $labels.job }} is DOWN" + description: | + Service {{ $labels.job }} (instance {{ $labels.instance }}) has been + unreachable for more than 1 minute. Check container health and logs. + runbook_url: "https://docs.goodgo.vn/runbooks/service-down" + + # ------------------------------------------------------------------- + # High 5xx Error Rate — > 5% of requests return 5xx for 5 minutes + # ------------------------------------------------------------------- + - alert: HighErrorRate + expr: | + ( + sum(rate(http_requests_received_total{code=~"5.."}[5m])) by (job) + / + sum(rate(http_requests_received_total[5m])) by (job) + ) > 0.05 + for: 5m + labels: + severity: critical + team: backend + annotations: + summary: "High 5xx error rate on {{ $labels.job }}" + description: | + Service {{ $labels.job }} has a 5xx error rate of {{ $value | humanizePercentage }} + over the last 5 minutes. Investigate application logs for exceptions. + runbook_url: "https://docs.goodgo.vn/runbooks/high-error-rate" + + # ------------------------------------------------------------------- + # High Latency — p95 response time > 2s for 5 minutes + # ------------------------------------------------------------------- + - alert: HighLatencyP95 + expr: | + histogram_quantile(0.95, + sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job) + ) > 2 + for: 5m + labels: + severity: warning + team: backend + annotations: + summary: "High p95 latency on {{ $labels.job }}" + description: | + Service {{ $labels.job }} p95 response time is {{ $value | humanizeDuration }} + (threshold: 2s). Check for slow database queries or external calls. + runbook_url: "https://docs.goodgo.vn/runbooks/high-latency" + + - name: infrastructure_health + interval: 60s + rules: + # ------------------------------------------------------------------- + # Database Connection Pool Exhausted — > 90% utilization + # ------------------------------------------------------------------- + - alert: DatabaseConnectionPoolExhausted + expr: | + ( + dotnet_npgsql_busy_connections + / + dotnet_npgsql_max_pool_size + ) > 0.9 + for: 2m + labels: + severity: critical + team: platform + annotations: + summary: "PostgreSQL connection pool near exhaustion on {{ $labels.job }}" + description: | + Service {{ $labels.job }} is using {{ $value | humanizePercentage }} of its + connection pool. Consider increasing MaxPoolSize or investigating connection leaks. + runbook_url: "https://docs.goodgo.vn/runbooks/db-pool-exhausted" + + # ------------------------------------------------------------------- + # Disk Usage > 85% + # ------------------------------------------------------------------- + - alert: DiskUsageHigh + expr: | + ( + node_filesystem_avail_bytes{mountpoint="/"} + / + node_filesystem_size_bytes{mountpoint="/"} + ) < 0.15 + for: 5m + labels: + severity: warning + team: devops + annotations: + summary: "Disk usage above 85% on {{ $labels.instance }}" + description: | + Node {{ $labels.instance }} has only {{ $value | humanizePercentage }} + disk space remaining. Clean up old data or expand storage. + runbook_url: "https://docs.goodgo.vn/runbooks/disk-usage" + + # ------------------------------------------------------------------- + # Memory Usage > 80% + # ------------------------------------------------------------------- + - alert: MemoryUsageHigh + expr: | + ( + 1 - ( + node_memory_MemAvailable_bytes + / + node_memory_MemTotal_bytes + ) + ) > 0.8 + for: 5m + labels: + severity: warning + team: devops + annotations: + summary: "Memory usage above 80% on {{ $labels.instance }}" + description: | + Node {{ $labels.instance }} memory usage is at {{ $value | humanizePercentage }}. + Check for memory leaks or scale horizontally. + runbook_url: "https://docs.goodgo.vn/runbooks/memory-usage" + + # ------------------------------------------------------------------- + # Redis Memory Usage > 80% + # ------------------------------------------------------------------- + - alert: RedisMemoryHigh + expr: | + redis_memory_used_bytes / redis_memory_max_bytes > 0.8 + for: 5m + labels: + severity: warning + team: platform + annotations: + summary: "Redis memory usage above 80%" + description: | + Redis is using {{ $value | humanizePercentage }} of max memory. + Review cache eviction policies and key TTLs. + + # ------------------------------------------------------------------- + # RabbitMQ Queue Backlog > 1000 messages + # ------------------------------------------------------------------- + - alert: RabbitMQQueueBacklog + expr: rabbitmq_queue_messages > 1000 + for: 5m + labels: + severity: warning + team: backend + annotations: + summary: "RabbitMQ queue {{ $labels.queue }} has backlog" + description: | + Queue {{ $labels.queue }} has {{ $value }} messages pending. + Check consumer health and throughput. diff --git a/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetRevenueAnalyticsQuery.cs b/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetRevenueAnalyticsQuery.cs new file mode 100644 index 00000000..2f1a1725 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetRevenueAnalyticsQuery.cs @@ -0,0 +1,250 @@ +// EN: Query for advanced revenue analytics with trends, payment methods, top products, and vertical breakdown. +// VI: Query cho phan tich doanh thu nang cao voi xu huong, phuong thuc thanh toan, san pham ban chay, va phan tich theo nganh. + +using System.Data; +using Dapper; +using MediatR; + +namespace OrderService.API.Application.Queries.Reports; + +/// +/// EN: Query for revenue analytics over a date range with configurable period grouping. +/// VI: Query phan tich doanh thu trong khoang thoi gian voi nhom theo ky co the cau hinh. +/// +public record GetRevenueAnalyticsQuery( + Guid ShopId, + DateTime StartDate, + DateTime EndDate, + string Period = "daily" +) : IRequest; + +/// +/// EN: Revenue analytics DTO with complete analytics summary. +/// VI: DTO phan tich doanh thu voi tom tat phan tich day du. +/// +public record RevenueAnalyticsDto( + decimal TotalRevenue, + decimal PreviousPeriodRevenue, + decimal GrowthPercentage, + int TotalOrders, + decimal AverageOrderValue, + List Trends, + List PaymentMethods, + List TopProducts, + List VerticalBreakdown +); + +/// +/// EN: Revenue trend data point (class for Dapper compatibility). +/// VI: Diem du lieu xu huong doanh thu (class cho tuong thich Dapper). +/// +public class RevenueTrendDto +{ + public DateTime Date { get; set; } + public decimal Revenue { get; set; } + public int OrderCount { get; set; } +} + +/// +/// EN: Payment method statistics (class for Dapper compatibility). +/// VI: Thong ke phuong thuc thanh toan (class cho tuong thich Dapper). +/// +public class PaymentMethodStatsDto +{ + public string Method { get; set; } = string.Empty; + public decimal Amount { get; set; } + public int Count { get; set; } + public decimal Percentage { get; set; } +} + +/// +/// EN: Top product analytics with quantity and revenue (class for Dapper compatibility). +/// VI: Phan tich san pham ban chay voi so luong va doanh thu (class cho tuong thich Dapper). +/// +public class TopProductAnalyticsDto +{ + public string Name { get; set; } = string.Empty; + public int QuantitySold { get; set; } + public decimal Revenue { get; set; } +} + +/// +/// EN: Revenue breakdown by business vertical (class for Dapper compatibility). +/// VI: Phan tich doanh thu theo nganh kinh doanh (class cho tuong thich Dapper). +/// +public class VerticalRevenueDto +{ + public string Vertical { get; set; } = string.Empty; + public decimal Revenue { get; set; } + public int OrderCount { get; set; } +} + +/// +/// EN: Handler for GetRevenueAnalyticsQuery — aggregates revenue data using Dapper for performance. +/// VI: Handler cho GetRevenueAnalyticsQuery — tong hop du lieu doanh thu bang Dapper cho hieu nang. +/// +public class GetRevenueAnalyticsQueryHandler : IRequestHandler +{ + private readonly IDbConnection _connection; + private readonly ILogger _logger; + + public GetRevenueAnalyticsQueryHandler(IDbConnection connection, ILogger logger) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(GetRevenueAnalyticsQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Generating revenue analytics for shop {ShopId} from {Start} to {End}, period {Period} / VI: Tao phan tich doanh thu cho shop {ShopId} tu {Start} den {End}, ky {Period}", + request.ShopId, request.StartDate.ToString("yyyy-MM-dd"), request.EndDate.ToString("yyyy-MM-dd"), request.Period); + + var startDate = request.StartDate.Date; + var endDate = request.EndDate.Date.AddDays(1); // EN: Inclusive end date / VI: Ngay ket thuc bao gom + + // EN: Calculate previous period for growth comparison / VI: Tinh ky truoc de so sanh tang truong + var periodLength = (endDate - startDate).TotalDays; + var prevStart = startDate.AddDays(-periodLength); + var prevEnd = startDate; + + var parameters = new DynamicParameters(); + parameters.Add("ShopId", request.ShopId); + parameters.Add("StartDate", startDate); + parameters.Add("EndDate", endDate); + parameters.Add("PrevStart", prevStart); + parameters.Add("PrevEnd", prevEnd); + parameters.Add("CancelledStatusId", 6); // EN: Cancelled status ID / VI: ID trang thai huy + + // EN: 1. Current period aggregate / VI: 1. Tong hop ky hien tai + var currentAggregateSql = @" + SELECT + COALESCE(SUM(o.total_amount), 0) AS TotalRevenue, + COUNT(*) AS TotalOrders, + COALESCE(AVG(o.total_amount), 0) AS AverageOrderValue + FROM orders o + WHERE o.shop_id = @ShopId + AND o.created_at >= @StartDate + AND o.created_at < @EndDate + AND o.status_id != @CancelledStatusId"; + + var currentAgg = await _connection.QuerySingleAsync(currentAggregateSql, parameters); + + // EN: 2. Previous period revenue for growth calculation / VI: 2. Doanh thu ky truoc de tinh tang truong + var prevRevenueSql = @" + SELECT COALESCE(SUM(o.total_amount), 0) + FROM orders o + WHERE o.shop_id = @ShopId + AND o.created_at >= @PrevStart + AND o.created_at < @PrevEnd + AND o.status_id != @CancelledStatusId"; + + var previousRevenue = await _connection.ExecuteScalarAsync(prevRevenueSql, parameters); + + // EN: Calculate growth percentage / VI: Tinh phan tram tang truong + var growthPercentage = previousRevenue > 0 + ? Math.Round((currentAgg.TotalRevenue - previousRevenue) / previousRevenue * 100, 2) + : currentAgg.TotalRevenue > 0 ? 100m : 0m; + + // EN: 3. Revenue trends grouped by period / VI: 3. Xu huong doanh thu nhom theo ky + 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)" + }; + + var trendsSql = $@" + SELECT + {truncExpr} AS Date, + COALESCE(SUM(o.total_amount), 0) AS Revenue, + COUNT(*)::int AS OrderCount + FROM orders o + WHERE o.shop_id = @ShopId + AND o.created_at >= @StartDate + AND o.created_at < @EndDate + AND o.status_id != @CancelledStatusId + GROUP BY {truncExpr} + ORDER BY Date"; + + var trends = (await _connection.QueryAsync(trendsSql, parameters)).AsList(); + + // EN: 4. Payment method breakdown / VI: 4. Phan tich phuong thuc thanh toan + var paymentSql = @" + SELECT + COALESCE(o.payment_method, 'unknown') AS Method, + COALESCE(SUM(o.total_amount), 0) AS Amount, + COUNT(*)::int AS Count, + 0 AS Percentage + FROM orders o + WHERE o.shop_id = @ShopId + AND o.created_at >= @StartDate + AND o.created_at < @EndDate + AND o.status_id != @CancelledStatusId + GROUP BY o.payment_method + ORDER BY Amount DESC"; + + var paymentMethods = (await _connection.QueryAsync(paymentSql, parameters)).AsList(); + + // EN: Calculate percentage for each payment method / VI: Tinh phan tram cho moi phuong thuc thanh toan + var totalPaymentAmount = paymentMethods.Sum(p => p.Amount); + foreach (var pm in paymentMethods) + { + pm.Percentage = totalPaymentAmount > 0 + ? Math.Round(pm.Amount / totalPaymentAmount * 100, 2) + : 0; + } + + // EN: 5. Top 10 products by revenue / VI: 5. Top 10 san pham theo doanh thu + var topProductsSql = @" + SELECT + oi.product_name AS Name, + SUM(oi.quantity)::int 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 >= @StartDate + AND o.created_at < @EndDate + AND o.status_id != @CancelledStatusId + GROUP BY oi.product_name + ORDER BY Revenue DESC + LIMIT 10"; + + var topProducts = (await _connection.QueryAsync(topProductsSql, parameters)).AsList(); + + // EN: 6. Revenue by business vertical / VI: 6. Doanh thu theo nganh kinh doanh + var verticalSql = @" + SELECT + COALESCE(o.vertical, 'other') AS Vertical, + COALESCE(SUM(o.total_amount), 0) AS Revenue, + COUNT(*)::int AS OrderCount + FROM orders o + WHERE o.shop_id = @ShopId + AND o.created_at >= @StartDate + AND o.created_at < @EndDate + AND o.status_id != @CancelledStatusId + GROUP BY o.vertical + ORDER BY Revenue DESC"; + + var verticalBreakdown = (await _connection.QueryAsync(verticalSql, parameters)).AsList(); + + return new RevenueAnalyticsDto( + currentAgg.TotalRevenue, + previousRevenue, + growthPercentage, + (int)currentAgg.TotalOrders, + currentAgg.AverageOrderValue, + trends, + paymentMethods, + topProducts, + verticalBreakdown); + } + + private record CurrentAggregateRow + { + public decimal TotalRevenue { get; init; } + public long TotalOrders { get; init; } + public decimal AverageOrderValue { get; init; } + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetStaffPerformanceQuery.cs b/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetStaffPerformanceQuery.cs new file mode 100644 index 00000000..5b600b79 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Queries/Reports/GetStaffPerformanceQuery.cs @@ -0,0 +1,123 @@ +// EN: Query for staff performance analytics — orders handled, revenue, completion rates. +// VI: Query cho phan tich hieu suat nhan vien — don xu ly, doanh thu, ty le hoan thanh. + +using System.Data; +using Dapper; +using MediatR; + +namespace OrderService.API.Application.Queries.Reports; + +/// +/// EN: Query for staff performance metrics over a date range. +/// VI: Query cho chi so hieu suat nhan vien trong khoang thoi gian. +/// +public record GetStaffPerformanceQuery( + Guid ShopId, + DateTime StartDate, + DateTime EndDate +) : IRequest; + +/// +/// EN: Staff performance report with individual metrics and shop average. +/// VI: Bao cao hieu suat nhan vien voi chi so ca nhan va trung binh cua hang. +/// +public record StaffPerformanceDto( + List Staff, + StaffMetricsDto ShopAverage +); + +/// +/// EN: Individual staff member metrics (class for Dapper compatibility). +/// VI: Chi so nhan vien ca nhan (class cho tuong thich Dapper). +/// +public class StaffMetricsDto +{ + public Guid? StaffId { get; set; } + public string StaffName { get; set; } = string.Empty; + public int OrdersHandled { get; set; } + public decimal TotalRevenue { get; set; } + public decimal AverageOrderValue { get; set; } + public int CompletedOrders { get; set; } + public int CancelledOrders { get; set; } + public double CompletionRate { get; set; } + public double AverageHandlingTimeMinutes { get; set; } +} + +/// +/// EN: Handler for GetStaffPerformanceQuery — aggregates staff order data using Dapper. +/// VI: Handler cho GetStaffPerformanceQuery — tong hop du lieu don hang nhan vien bang Dapper. +/// +public class GetStaffPerformanceQueryHandler : IRequestHandler +{ + private readonly IDbConnection _connection; + private readonly ILogger _logger; + + public GetStaffPerformanceQueryHandler(IDbConnection connection, ILogger logger) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(GetStaffPerformanceQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Generating staff performance report for shop {ShopId} from {Start} to {End} / VI: Tao bao cao hieu suat nhan vien cho shop {ShopId} tu {Start} den {End}", + request.ShopId, request.StartDate.ToString("yyyy-MM-dd"), request.EndDate.ToString("yyyy-MM-dd")); + + var startDate = request.StartDate.Date; + var endDate = request.EndDate.Date.AddDays(1); + + var parameters = new DynamicParameters(); + parameters.Add("ShopId", request.ShopId); + parameters.Add("StartDate", startDate); + parameters.Add("EndDate", endDate); + parameters.Add("CompletedStatusId", 5); + parameters.Add("CancelledStatusId", 6); + + // EN: Staff performance metrics grouped by staff member + // VI: Chi so hieu suat nhan vien nhom theo nhan vien + var staffSql = @" + SELECT + o.staff_id AS StaffId, + COALESCE(o.staff_name, 'Chua gan') AS StaffName, + COUNT(*)::int AS OrdersHandled, + COALESCE(SUM(o.total_amount) FILTER (WHERE o.status_id != @CancelledStatusId), 0) AS TotalRevenue, + COALESCE(AVG(o.total_amount) FILTER (WHERE o.status_id != @CancelledStatusId), 0) AS AverageOrderValue, + COUNT(*) FILTER (WHERE o.status_id = @CompletedStatusId)::int AS CompletedOrders, + COUNT(*) FILTER (WHERE o.status_id = @CancelledStatusId)::int AS CancelledOrders, + CASE + WHEN COUNT(*) > 0 + THEN ROUND(COUNT(*) FILTER (WHERE o.status_id = @CompletedStatusId)::numeric / COUNT(*)::numeric * 100, 2) + ELSE 0 + END AS CompletionRate, + COALESCE( + AVG(EXTRACT(EPOCH FROM (o.completed_at - o.created_at)) / 60.0) + FILTER (WHERE o.completed_at IS NOT NULL AND o.status_id = @CompletedStatusId), + 0 + ) AS AverageHandlingTimeMinutes + FROM orders o + WHERE o.shop_id = @ShopId + AND o.created_at >= @StartDate + AND o.created_at < @EndDate + GROUP BY o.staff_id, o.staff_name + ORDER BY TotalRevenue DESC"; + + var staffMetrics = (await _connection.QueryAsync(staffSql, parameters)).AsList(); + + // EN: Calculate shop average / VI: Tinh trung binh cua hang + var shopAverage = new StaffMetricsDto + { + StaffId = null, + StaffName = "Trung binh cua hang", + OrdersHandled = staffMetrics.Any() ? (int)Math.Round(staffMetrics.Average(s => s.OrdersHandled)) : 0, + TotalRevenue = staffMetrics.Any() ? Math.Round(staffMetrics.Average(s => s.TotalRevenue), 0) : 0, + AverageOrderValue = staffMetrics.Any() ? Math.Round(staffMetrics.Average(s => s.AverageOrderValue), 0) : 0, + CompletedOrders = staffMetrics.Any() ? (int)Math.Round(staffMetrics.Average(s => s.CompletedOrders)) : 0, + CancelledOrders = staffMetrics.Any() ? (int)Math.Round(staffMetrics.Average(s => s.CancelledOrders)) : 0, + CompletionRate = staffMetrics.Any() ? Math.Round(staffMetrics.Average(s => s.CompletionRate), 2) : 0, + AverageHandlingTimeMinutes = staffMetrics.Any() ? Math.Round(staffMetrics.Average(s => s.AverageHandlingTimeMinutes), 1) : 0 + }; + + return new StaffPerformanceDto(staffMetrics, shopAverage); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/GetRevenueAnalyticsQueryValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/GetRevenueAnalyticsQueryValidator.cs new file mode 100644 index 00000000..0bad13e7 --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Validations/GetRevenueAnalyticsQueryValidator.cs @@ -0,0 +1,41 @@ +// EN: Validator for GetRevenueAnalyticsQuery. +// VI: Validator cho GetRevenueAnalyticsQuery. + +using FluentValidation; +using OrderService.API.Application.Queries.Reports; + +namespace OrderService.API.Application.Validations; + +/// +/// EN: Validator for GetRevenueAnalyticsQuery — validates date range, period, and shop ID. +/// VI: Validator cho GetRevenueAnalyticsQuery — kiem tra khoang ngay, ky, va shop ID. +/// +public class GetRevenueAnalyticsQueryValidator : AbstractValidator +{ + private static readonly string[] ValidPeriods = { "daily", "weekly", "monthly" }; + + public GetRevenueAnalyticsQueryValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("EN: Shop ID is required / VI: Shop ID la bat buoc"); + + RuleFor(x => x.StartDate) + .NotEmpty() + .WithMessage("EN: Start date is required / VI: Ngay bat dau la bat buoc") + .LessThan(x => x.EndDate) + .WithMessage("EN: Start date must be before end date / VI: Ngay bat dau phai truoc ngay ket thuc"); + + RuleFor(x => x.EndDate) + .NotEmpty() + .WithMessage("EN: End date is required / VI: Ngay ket thuc la bat buoc") + .Must(d => d <= DateTime.UtcNow.Date.AddDays(1)) + .WithMessage("EN: End date cannot be in the future / VI: Ngày kết thúc không thể trong tương lai"); + + RuleFor(x => x.Period) + .NotEmpty() + .WithMessage("EN: Period is required / VI: Ky la bat buoc") + .Must(p => ValidPeriods.Contains(p.ToLowerInvariant())) + .WithMessage("EN: Period must be 'daily', 'weekly', or 'monthly' / VI: Ky phai la 'daily', 'weekly', hoac 'monthly'"); + } +} diff --git a/services/order-service-net/src/OrderService.API/Application/Validations/GetStaffPerformanceQueryValidator.cs b/services/order-service-net/src/OrderService.API/Application/Validations/GetStaffPerformanceQueryValidator.cs new file mode 100644 index 00000000..6f74a89a --- /dev/null +++ b/services/order-service-net/src/OrderService.API/Application/Validations/GetStaffPerformanceQueryValidator.cs @@ -0,0 +1,33 @@ +// EN: Validator for GetStaffPerformanceQuery. +// VI: Validator cho GetStaffPerformanceQuery. + +using FluentValidation; +using OrderService.API.Application.Queries.Reports; + +namespace OrderService.API.Application.Validations; + +/// +/// EN: Validator for GetStaffPerformanceQuery — validates date range and shop ID. +/// VI: Validator cho GetStaffPerformanceQuery — kiem tra khoang ngay va shop ID. +/// +public class GetStaffPerformanceQueryValidator : AbstractValidator +{ + public GetStaffPerformanceQueryValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("EN: Shop ID is required / VI: Shop ID la bat buoc"); + + RuleFor(x => x.StartDate) + .NotEmpty() + .WithMessage("EN: Start date is required / VI: Ngay bat dau la bat buoc") + .LessThan(x => x.EndDate) + .WithMessage("EN: Start date must be before end date / VI: Ngay bat dau phai truoc ngay ket thuc"); + + RuleFor(x => x.EndDate) + .NotEmpty() + .WithMessage("EN: End date is required / VI: Ngay ket thuc la bat buoc") + .Must(d => d <= DateTime.UtcNow.Date.AddDays(1)) + .WithMessage("EN: End date cannot be in the future / VI: Ngày kết thúc không thể trong tương lai"); + } +} diff --git a/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs b/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs index 47da16b3..b14fb928 100644 --- a/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs +++ b/services/order-service-net/src/OrderService.API/Controllers/ReportsController.cs @@ -74,6 +74,53 @@ public class ReportsController : ControllerBase return Ok(result); } + /// + /// EN: Get advanced revenue analytics with trends, payment methods, top products, and vertical breakdown. + /// VI: Lay phan tich doanh thu nang cao voi xu huong, phuong thuc thanh toan, san pham ban chay, va phan tich theo nganh. + /// + [HttpGet("revenue-analytics")] + [ProducesResponseType(typeof(RevenueAnalyticsDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> GetRevenueAnalytics( + [FromQuery] Guid shopId, + [FromQuery] DateTime startDate, + [FromQuery] DateTime endDate, + [FromQuery] string period = "daily", + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Getting revenue analytics for shop {ShopId}, {Start} to {End}, period {Period} / VI: Lay phan tich doanh thu cho shop {ShopId}, {Start} den {End}, ky {Period}", + shopId, startDate.ToString("yyyy-MM-dd"), endDate.ToString("yyyy-MM-dd"), period); + + var query = new GetRevenueAnalyticsQuery(shopId, startDate, endDate, period); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(new { success = true, data = result }); + } + + /// + /// EN: Get staff performance metrics for a shop over a date range. + /// VI: Lay chi so hieu suat nhan vien cho shop trong khoang thoi gian. + /// + [HttpGet("staff-performance")] + [ProducesResponseType(typeof(StaffPerformanceDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> GetStaffPerformance( + [FromQuery] Guid shopId, + [FromQuery] DateTime startDate, + [FromQuery] DateTime endDate, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "EN: Getting staff performance for shop {ShopId}, {Start} to {End} / VI: Lay hieu suat nhan vien cho shop {ShopId}, {Start} den {End}", + shopId, startDate.ToString("yyyy-MM-dd"), endDate.ToString("yyyy-MM-dd")); + + var query = new GetStaffPerformanceQuery(shopId, startDate, endDate); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(new { success = true, data = result }); + } + /// /// EN: Get End-of-Day report for a shop on a specific date. /// VI: Lay bao cao cuoi ngay cho shop vao ngay cu the.