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:
Ho Ngoc Hai
2026-03-06 19:51:37 +07:00
parent 870f1218f8
commit dc1ea7c0d2
25 changed files with 2618 additions and 0 deletions

View File

@@ -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();
}
}

View File

@@ -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; }
}
}

View File

@@ -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.

View File

@@ -0,0 +1,6 @@
node_modules/
dist/
test-results/
playwright-report/
blob-report/
.playwright/

View File

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

View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -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,
// },
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]
}

View File

@@ -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

View 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
}

View 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.

View File

@@ -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; }
}
}

View File

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

View File

@@ -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'");
}
}

View File

@@ -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");
}
}

View File

@@ -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.