feat: Phase 2 W7-8 production readiness — QR menu, analytics, E2E tests, observability
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1437,6 +1437,84 @@ public class PosDataService
|
||||
return await PostAndGetAsync<CloseDayResultInfo>(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<RevenueTrendInfo> Trends,
|
||||
List<PaymentMethodStatsInfo> PaymentMethods,
|
||||
List<TopProductAnalyticsInfo> TopProducts,
|
||||
List<VerticalRevenueInfo> 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<StaffMetricsInfo> 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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<RevenueAnalyticsInfo?> 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<RevenueAnalyticsInfo>(url);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<StaffPerformanceInfo?> 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<StaffPerformanceInfo>(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<PublicMenuItem> Items);
|
||||
public record PublicMenuItem(Guid Id, string Name, string? Description, decimal Price, string? ImageUrl, bool IsAvailable);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<PublicShopInfo?> 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<PublicShopInfo>(data.GetRawText(), _jsonOptions);
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
return JsonSerializer.Deserialize<PublicShopInfo>(json, _jsonOptions);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<List<PublicMenuCategory>> 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<List<PublicMenuCategory>>(data.GetRawText(), _jsonOptions) ?? new();
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
return JsonSerializer.Deserialize<List<PublicMenuCategory>>(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<List<PublicMenuCategory>>(items.GetRawText(), _jsonOptions) ?? new();
|
||||
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace WebClientTpos.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<PublicMenuController> _logger;
|
||||
|
||||
public PublicMenuController(IHttpClientFactory httpClientFactory, ILogger<PublicMenuController> logger)
|
||||
{
|
||||
_merchant = httpClientFactory.CreateClient("MerchantService");
|
||||
_catalog = httpClientFactory.CreateClient("CatalogService");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("shops/{shopId:guid}")]
|
||||
public async Task<IActionResult> 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" } });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("shops/{shopId:guid}/menu")]
|
||||
public async Task<IActionResult> 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<CategoryDto>();
|
||||
if (categoriesResp.IsSuccessStatusCode)
|
||||
{
|
||||
var catJson = await categoriesResp.Content.ReadAsStringAsync();
|
||||
categories = ParseList<CategoryDto>(catJson);
|
||||
}
|
||||
|
||||
// EN: Parse products
|
||||
// VI: Phân tích sản phẩm
|
||||
var products = new List<ProductDto>();
|
||||
if (productsResp.IsSuccessStatusCode)
|
||||
{
|
||||
var prodJson = await productsResp.Content.ReadAsStringAsync();
|
||||
products = ParseList<ProductDto>(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<T> ParseList<T>(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<List<T>>(json, JsonOpts) ?? [];
|
||||
|
||||
if (root.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array)
|
||||
return JsonSerializer.Deserialize<List<T>>(items.GetRawText(), JsonOpts) ?? [];
|
||||
|
||||
if (root.TryGetProperty("data", out var data))
|
||||
{
|
||||
if (data.ValueKind == JsonValueKind.Array)
|
||||
return JsonSerializer.Deserialize<List<T>>(data.GetRawText(), JsonOpts) ?? [];
|
||||
if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("items", out var dataItems) && dataItems.ValueKind == JsonValueKind.Array)
|
||||
return JsonSerializer.Deserialize<List<T>>(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; }
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,46 @@ public class ReportsController : ControllerBase
|
||||
return _order.GetAsync($"/api/v1/reports/top-products?{string.Join("&", qs)}").ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("reports/revenue-analytics")]
|
||||
public Task<IActionResult> GetRevenueAnalytics(
|
||||
[FromQuery] Guid shopId,
|
||||
[FromQuery] string startDate,
|
||||
[FromQuery] string endDate,
|
||||
[FromQuery] string period = "daily")
|
||||
{
|
||||
var qs = new List<string>
|
||||
{
|
||||
$"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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get staff performance metrics for a shop.
|
||||
/// VI: Lay chi so hieu suat nhan vien cho shop.
|
||||
/// </summary>
|
||||
[HttpGet("reports/staff-performance")]
|
||||
public Task<IActionResult> GetStaffPerformance(
|
||||
[FromQuery] Guid shopId,
|
||||
[FromQuery] string startDate,
|
||||
[FromQuery] string endDate)
|
||||
{
|
||||
var qs = new List<string>
|
||||
{
|
||||
$"shopId={shopId}",
|
||||
$"startDate={Uri.EscapeDataString(startDate)}",
|
||||
$"endDate={Uri.EscapeDataString(endDate)}"
|
||||
};
|
||||
return _order.GetAsync($"/api/v1/reports/staff-performance?{string.Join("&", qs)}").ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get End-of-Day report for a shop.
|
||||
/// VI: Lay bao cao cuoi ngay cho shop.
|
||||
|
||||
6
apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/.gitignore
vendored
Normal file
6
apps/web-client-tpos-net/tests/WebClientTpos.E2ETests/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
.playwright/
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
// Clear the JWT token from localStorage (key: aPOS_token)
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('aPOS_token');
|
||||
});
|
||||
await page.goto(ROUTES.LOGIN);
|
||||
await waitForBlazor(page);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
// },
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
549
infra/observability/grafana/dashboards/goodgo-overview.json
Normal file
549
infra/observability/grafana/dashboards/goodgo-overview.json
Normal file
@@ -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
|
||||
}
|
||||
165
infra/observability/prometheus/alert-rules.yml
Normal file
165
infra/observability/prometheus/alert-rules.yml
Normal file
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record GetRevenueAnalyticsQuery(
|
||||
Guid ShopId,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
string Period = "daily"
|
||||
) : IRequest<RevenueAnalyticsDto>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Revenue analytics DTO with complete analytics summary.
|
||||
/// VI: DTO phan tich doanh thu voi tom tat phan tich day du.
|
||||
/// </summary>
|
||||
public record RevenueAnalyticsDto(
|
||||
decimal TotalRevenue,
|
||||
decimal PreviousPeriodRevenue,
|
||||
decimal GrowthPercentage,
|
||||
int TotalOrders,
|
||||
decimal AverageOrderValue,
|
||||
List<RevenueTrendDto> Trends,
|
||||
List<PaymentMethodStatsDto> PaymentMethods,
|
||||
List<TopProductAnalyticsDto> TopProducts,
|
||||
List<VerticalRevenueDto> VerticalBreakdown
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Revenue trend data point (class for Dapper compatibility).
|
||||
/// VI: Diem du lieu xu huong doanh thu (class cho tuong thich Dapper).
|
||||
/// </summary>
|
||||
public class RevenueTrendDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public decimal Revenue { get; set; }
|
||||
public int OrderCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Payment method statistics (class for Dapper compatibility).
|
||||
/// VI: Thong ke phuong thuc thanh toan (class cho tuong thich Dapper).
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public class TopProductAnalyticsDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int QuantitySold { get; set; }
|
||||
public decimal Revenue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Revenue breakdown by business vertical (class for Dapper compatibility).
|
||||
/// VI: Phan tich doanh thu theo nganh kinh doanh (class cho tuong thich Dapper).
|
||||
/// </summary>
|
||||
public class VerticalRevenueDto
|
||||
{
|
||||
public string Vertical { get; set; } = string.Empty;
|
||||
public decimal Revenue { get; set; }
|
||||
public int OrderCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class GetRevenueAnalyticsQueryHandler : IRequestHandler<GetRevenueAnalyticsQuery, RevenueAnalyticsDto>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
private readonly ILogger<GetRevenueAnalyticsQueryHandler> _logger;
|
||||
|
||||
public GetRevenueAnalyticsQueryHandler(IDbConnection connection, ILogger<GetRevenueAnalyticsQueryHandler> logger)
|
||||
{
|
||||
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RevenueAnalyticsDto> 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<CurrentAggregateRow>(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<decimal>(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<RevenueTrendDto>(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<PaymentMethodStatsDto>(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<TopProductAnalyticsDto>(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<VerticalRevenueDto>(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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query for staff performance metrics over a date range.
|
||||
/// VI: Query cho chi so hieu suat nhan vien trong khoang thoi gian.
|
||||
/// </summary>
|
||||
public record GetStaffPerformanceQuery(
|
||||
Guid ShopId,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate
|
||||
) : IRequest<StaffPerformanceDto>;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record StaffPerformanceDto(
|
||||
List<StaffMetricsDto> Staff,
|
||||
StaffMetricsDto ShopAverage
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Individual staff member metrics (class for Dapper compatibility).
|
||||
/// VI: Chi so nhan vien ca nhan (class cho tuong thich Dapper).
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetStaffPerformanceQuery — aggregates staff order data using Dapper.
|
||||
/// VI: Handler cho GetStaffPerformanceQuery — tong hop du lieu don hang nhan vien bang Dapper.
|
||||
/// </summary>
|
||||
public class GetStaffPerformanceQueryHandler : IRequestHandler<GetStaffPerformanceQuery, StaffPerformanceDto>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
private readonly ILogger<GetStaffPerformanceQueryHandler> _logger;
|
||||
|
||||
public GetStaffPerformanceQueryHandler(IDbConnection connection, ILogger<GetStaffPerformanceQueryHandler> logger)
|
||||
{
|
||||
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<StaffPerformanceDto> 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<StaffMetricsDto>(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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for GetRevenueAnalyticsQuery — validates date range, period, and shop ID.
|
||||
/// VI: Validator cho GetRevenueAnalyticsQuery — kiem tra khoang ngay, ky, va shop ID.
|
||||
/// </summary>
|
||||
public class GetRevenueAnalyticsQueryValidator : AbstractValidator<GetRevenueAnalyticsQuery>
|
||||
{
|
||||
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'");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for GetStaffPerformanceQuery — validates date range and shop ID.
|
||||
/// VI: Validator cho GetStaffPerformanceQuery — kiem tra khoang ngay va shop ID.
|
||||
/// </summary>
|
||||
public class GetStaffPerformanceQueryValidator : AbstractValidator<GetStaffPerformanceQuery>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,53 @@ public class ReportsController : ControllerBase
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("revenue-analytics")]
|
||||
[ProducesResponseType(typeof(RevenueAnalyticsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<RevenueAnalyticsDto>> 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("staff-performance")]
|
||||
[ProducesResponseType(typeof(StaffPerformanceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<StaffPerformanceDto>> 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
|
||||
Reference in New Issue
Block a user