Files
pos-system/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs
Ho Ngoc Hai 0959f594bd fix(bff): add try-catch to GetWallets and GetWalletTransactions
- Prevent 500 errors when wallet_service tables are missing
- Return empty arrays on failure for graceful Finance page display
- Now 7 total BFF endpoints hardened against missing tables
2026-03-01 04:14:36 +07:00

741 lines
32 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Npgsql;
using Dapper;
using System.IdentityModel.Tokens.Jwt;
namespace WebClientTpos.Server.Controllers;
// EN: BFF controller with multi-tenant data isolation.
// All queries are scoped to the current user's merchant.
// JWT token is parsed manually from Authorization header (no middleware dependency).
// VI: BFF controller với cách ly dữ liệu multi-tenant.
// Tất cả queries đều lọc theo merchant của user hiện tại.
// JWT token được parse thủ công từ header Authorization (không phụ thuộc middleware).
[ApiController]
[Route("api/bff")]
public class BffDataController : ControllerBase
{
// EN: DB host configurable via env var (Docker: "postgres", dev: "localhost")
// VI: DB host cấu hình qua env var (Docker: "postgres", dev: "localhost")
private static readonly string _dbHost = Environment.GetEnvironmentVariable("BFF_DB_HOST") ?? "localhost";
private static readonly string _dbPort = Environment.GetEnvironmentVariable("BFF_DB_PORT") ?? "5432";
private static readonly string _dbUser = Environment.GetEnvironmentVariable("BFF_DB_USER") ?? "goodgo";
private static readonly string _dbPass = Environment.GetEnvironmentVariable("BFF_DB_PASS") ?? "goodgo_dev_2024";
private static string ConnStr(string db) =>
$"Host={_dbHost};Port={_dbPort};Database={db};Username={_dbUser};Password={_dbPass}";
// ═══ TENANT RESOLUTION HELPERS ═══
/// <summary>
/// EN: Extract user ID from JWT token in Authorization header.
/// Parses the token manually without middleware validation.
/// VI: Trích xuất user ID từ JWT token trong header Authorization.
/// Parse token thủ công không cần middleware validation.
/// </summary>
private Guid? GetUserIdFromToken()
{
var authHeader = Request.Headers["Authorization"].FirstOrDefault();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return null;
var tokenStr = authHeader["Bearer ".Length..].Trim();
try
{
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(tokenStr);
var sub = jwt.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
if (!string.IsNullOrEmpty(sub) && Guid.TryParse(sub, out var userId))
return userId;
}
catch { /* Invalid token — return null */ }
return null;
}
/// <summary>
/// EN: Extract current user's merchant ID from JWT → merchants table.
/// VI: Lấy merchant ID của user hiện tại từ JWT → bảng merchants.
/// </summary>
private async Task<Guid?> GetCurrentMerchantIdAsync()
{
var userId = GetUserIdFromToken();
if (userId == null)
return null;
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
return await conn.QueryFirstOrDefaultAsync<Guid?>(
"SELECT id FROM merchants WHERE user_id = @UserId AND is_deleted = false",
new { UserId = userId });
}
/// <summary>
/// EN: Get list of shop IDs owned by the current merchant.
/// VI: Lấy danh sách shop IDs thuộc sở hữu của merchant hiện tại.
/// </summary>
private async Task<List<Guid>> GetMyShopIdsAsync(Guid merchantId)
{
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var ids = await conn.QueryAsync<Guid>(
"SELECT id FROM shops WHERE merchant_id = @MerchantId AND is_deleted = false",
new { MerchantId = merchantId });
return ids.ToList();
}
// ═══ SHOP ENDPOINTS ═══
[HttpGet("shops")]
public async Task<IActionResult> GetShops()
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>()); // EN: No merchant → no shops / VI: Không có merchant → không có shops
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var shops = await conn.QueryAsync<dynamic>(
@"SELECT s.id, s.name, s.slug, s.description, s.phone, s.email,
s.open_time, s.close_time, s.features_config,
bc.name as category, st.name as status
FROM shops s
JOIN business_categories bc ON s.category_id = bc.id
JOIN shop_statuses st ON s.status_id = st.id
WHERE s.merchant_id = @MerchantId AND s.is_deleted = false
ORDER BY s.name",
new { MerchantId = merchantId });
return Ok(shops);
}
[HttpGet("shops/{shopId:guid}")]
public async Task<IActionResult> GetShopById(Guid shopId)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return NotFound(new { message = "Shop not found" });
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var shop = await conn.QueryFirstOrDefaultAsync<dynamic>(
@"SELECT s.id, s.name, s.slug, s.description, s.phone, s.email,
s.open_time, s.close_time,
bc.name as category, st.name as status
FROM shops s
JOIN business_categories bc ON s.category_id = bc.id
JOIN shop_statuses st ON s.status_id = st.id
WHERE s.id = @ShopId AND s.merchant_id = @MerchantId AND s.is_deleted = false",
new { ShopId = shopId, MerchantId = merchantId });
if (shop == null)
return NotFound(new { message = "Shop not found" });
return Ok(shop);
}
[HttpGet("staff")]
public async Task<IActionResult> GetStaff()
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var staff = await conn.QueryAsync<dynamic>(
@"SELECT ms.id, ms.user_id, ms.employee_code, ms.phone, ms.email,
ms.joined_at, ms.terminated_at,
sr.name as role, ss.name as status,
s.name as shop_name
FROM merchant_staff ms
JOIN staff_roles sr ON ms.role_id = sr.id
JOIN staff_statuses ss ON ms.status_id = ss.id
LEFT JOIN shop_members sm ON sm.staff_id = ms.id
LEFT JOIN shops s ON sm.shop_id = s.id
WHERE ms.merchant_id = @MerchantId
ORDER BY ms.joined_at DESC",
new { MerchantId = merchantId });
return Ok(staff);
}
[HttpGet("shops/{shopId}/products")]
public async Task<IActionResult> GetProducts(Guid shopId)
{
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
var products = await conn.QueryAsync<dynamic>(
@"SELECT id, name, price, sku, description, image_url, is_active,
attributes->>'category' as category,
(attributes->>'duration')::int as duration_minutes
FROM products
WHERE shop_id = @ShopId AND is_active = true
ORDER BY name",
new { ShopId = shopId });
return Ok(products);
}
[HttpGet("shops/{shopId}/categories")]
public async Task<IActionResult> GetCategories(Guid shopId)
{
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
var categories = await conn.QueryAsync<dynamic>(
@"SELECT id, name, description, display_order
FROM categories
WHERE shop_id = @ShopId AND is_active = true
ORDER BY display_order",
new { ShopId = shopId });
return Ok(categories);
}
[HttpGet("shops/{shopId}/tables")]
public async Task<IActionResult> GetTables(Guid shopId)
{
await using var conn = new NpgsqlConnection(ConnStr("fnb_engine"));
var tables = await conn.QueryAsync<dynamic>(
@"SELECT t.id, t.table_number, t.capacity, t.zone,
CASE t.status_id
WHEN 1 THEN 'available'
WHEN 2 THEN 'occupied'
WHEN 3 THEN 'reserved'
WHEN 4 THEN 'cleaning'
END as status,
s.id as session_id, s.guest_count, s.started_at
FROM tables t
LEFT JOIN sessions s ON s.table_id = t.id AND s.status = 'Active'
WHERE t.shop_id = @ShopId
ORDER BY t.table_number",
new { ShopId = shopId });
return Ok(tables);
}
[HttpGet("shops/{shopId}/appointments")]
public async Task<IActionResult> GetAppointments(Guid shopId)
{
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
var appointments = await conn.QueryAsync<dynamic>(
@"SELECT a.id, a.customer_id, a.staff_id, a.resource_id,
a.service_id, a.start_time, a.end_time, a.status,
r.name as resource_name
FROM appointments a
LEFT JOIN resources r ON a.resource_id = r.id
WHERE a.shop_id = @ShopId
ORDER BY a.start_time",
new { ShopId = shopId });
return Ok(appointments);
}
[HttpGet("shops/{shopId}/resources")]
public async Task<IActionResult> GetResources(Guid shopId)
{
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
var resources = await conn.QueryAsync<dynamic>(
@"SELECT id, name, resource_type, capacity, is_active
FROM resources
WHERE shop_id = @ShopId AND is_active = true
ORDER BY name",
new { ShopId = shopId });
return Ok(resources);
}
// ═══ MERCHANT-SCOPED PRODUCT ENDPOINTS ═══
/// <summary>
/// EN: Get products belonging to the current merchant's shops.
/// VI: Lấy sản phẩm thuộc các cửa hàng của merchant hiện tại.
/// </summary>
[HttpGet("products")]
public async Task<IActionResult> GetAllProducts([FromQuery] Guid? shopId = null)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
// EN: If shopId specified, verify ownership / VI: Nếu có shopId, kiểm tra quyền sở hữu
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
var products = await conn.QueryAsync<dynamic>(
@"SELECT p.id, p.name, p.price, p.sku, p.description, p.image_url,
p.is_active, pt.name as type, p.shop_id, p.created_at,
'' as category_name
FROM products p
JOIN product_types pt ON p.type_id = pt.id
WHERE p.shop_id = ANY(@ShopIds)
ORDER BY p.name",
new { ShopIds = targetShopIds.ToArray() });
return Ok(products);
}
/// <summary>
/// EN: Get categories belonging to the current merchant's shops.
/// VI: Lấy danh mục thuộc các cửa hàng của merchant hiện tại.
/// </summary>
[HttpGet("categories")]
public async Task<IActionResult> GetAllCategories([FromQuery] Guid? shopId = null)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
var categories = await conn.QueryAsync<dynamic>(
@"SELECT id, name, description, display_order, shop_id, parent_id, is_active
FROM categories
WHERE is_active = true AND shop_id = ANY(@ShopIds)
ORDER BY display_order, name",
new { ShopIds = targetShopIds.ToArray() });
return Ok(categories);
}
/// <summary>
/// EN: Create a product — validates shop ownership first.
/// VI: Tạo sản phẩm — kiểm tra quyền sở hữu shop trước.
/// </summary>
[HttpPost("products")]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest req)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Forbid();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Contains(req.ShopId))
return Forbid(); // EN: Cannot create product in another merchant's shop
var id = Guid.NewGuid();
var typeId = (req.Type ?? "PreparedFood") switch
{
"Physical" => 1,
"Service" => 2,
"PreparedFood" => 3,
_ => 3
};
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
await conn.ExecuteAsync(
@"INSERT INTO products (id, shop_id, name, description, price, type_id, sku, image_url, is_active, created_at)
VALUES (@Id, @ShopId, @Name, @Description, @Price, @TypeId, @Sku, @ImageUrl, true, NOW())",
new { Id = id, req.ShopId, req.Name, req.Description, req.Price, TypeId = typeId, req.Sku, req.ImageUrl });
return CreatedAtAction(nameof(GetAllProducts), new { }, new { id });
}
/// <summary>
/// EN: Delete (deactivate) a product — validates ownership first.
/// VI: Xóa (vô hiệu hóa) sản phẩm — kiểm tra quyền sở hữu trước.
/// </summary>
[HttpDelete("products/{productId:guid}")]
public async Task<IActionResult> DeleteProduct(Guid productId)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Forbid();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
// EN: Only delete if product belongs to one of merchant's shops
// VI: Chỉ xóa nếu sản phẩm thuộc shop của merchant
await conn.ExecuteAsync(
"UPDATE products SET is_active = false WHERE id = @Id AND shop_id = ANY(@ShopIds)",
new { Id = productId, ShopIds = myShopIds.ToArray() });
return NoContent();
}
// ═══ INVENTORY ENDPOINTS ═══
/// <summary>
/// EN: Get inventory items with product name (cross-DB join via subquery).
/// VI: Lấy danh sách tồn kho với tên sản phẩm.
/// </summary>
[HttpGet("inventory")]
public async Task<IActionResult> GetInventory([FromQuery] Guid? shopId = null)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("inventory_service"));
var items = await conn.QueryAsync<dynamic>(
@"SELECT id, product_id, shop_id, quantity, reorder_level, reserved_quantity, updated_at
FROM inventory_items
WHERE shop_id = ANY(@ShopIds)
ORDER BY quantity ASC",
new { ShopIds = targetShopIds.ToArray() });
// EN: Enrich with product names from catalog_service (scoped)
// VI: Bổ sung tên sản phẩm từ catalog_service (đã lọc)
await using var catConn = new NpgsqlConnection(ConnStr("catalog_service"));
var products = (await catConn.QueryAsync<dynamic>(
"SELECT id, name FROM products WHERE shop_id = ANY(@ShopIds)",
new { ShopIds = targetShopIds.ToArray() })).ToList();
var prodMap = products.ToDictionary(p => (Guid)p.id, p => (string)p.name);
var result = items.Select(i => new
{
i.id, i.product_id, i.shop_id, i.quantity, i.reorder_level, i.reserved_quantity, i.updated_at,
product_name = prodMap.TryGetValue((Guid)i.product_id, out var name) ? name : "Unknown"
});
return Ok(result);
}
// ═══ MEMBERSHIP/CUSTOMER ENDPOINTS ═══
/// <summary>
/// EN: Get all members (customers).
/// VI: Lấy danh sách thành viên (khách hàng).
/// </summary>
[HttpGet("members")]
public async Task<IActionResult> GetMembers()
{
try
{
await using var conn = new NpgsqlConnection(ConnStr("membership_service"));
var members = await conn.QueryAsync<dynamic>(
@"SELECT m.id, m.country_code, m.gender, m.current_exp, m.current_level,
m.total_exp_earned, m.created_at, m.preferences,
ml.name as level_name
FROM members m
LEFT JOIN membership_levels ml ON m.current_level = ml.level
WHERE m.is_deleted = false
ORDER BY m.created_at DESC");
return Ok(members);
}
catch { return Ok(Array.Empty<object>()); }
}
// ═══ STAFF CREATE ENDPOINT ═══
/// <summary>
/// EN: Create a staff member — validates merchant ownership.
/// VI: Tạo nhân viên mới — kiểm tra quyền sở hữu merchant.
/// </summary>
[HttpPost("staff")]
public async Task<IActionResult> CreateStaff([FromBody] CreateStaffRequest req)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null || merchantId.Value != req.MerchantId)
return Forbid(); // EN: Cannot create staff for another merchant
var id = Guid.NewGuid();
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var roleId = await conn.QueryFirstOrDefaultAsync<int>(
"SELECT id FROM staff_roles WHERE name = @Role", new { req.Role }) ;
if (roleId == 0) roleId = 1;
var statusId = await conn.QueryFirstOrDefaultAsync<int>(
"SELECT id FROM staff_statuses WHERE name = 'Active'");
if (statusId == 0) statusId = 1;
await conn.ExecuteAsync(
@"INSERT INTO merchant_staff (id, merchant_id, employee_code, phone, email, role_id, status_id, joined_at)
VALUES (@Id, @MerchantId, @EmployeeCode, @Phone, @Email, @RoleId, @StatusId, NOW())",
new { Id = id, req.MerchantId, req.EmployeeCode, req.Phone, req.Email, RoleId = roleId, StatusId = statusId });
return CreatedAtAction(nameof(GetStaff), new { }, new { id });
}
// ═══ STAFF ROLES ═══
[HttpGet("staff/roles")]
public async Task<IActionResult> GetStaffRoles()
{
try
{
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var roles = await conn.QueryAsync<dynamic>("SELECT id, name FROM staff_roles ORDER BY id");
return Ok(roles);
}
catch { return Ok(Array.Empty<object>()); }
}
// ═══ STAFF SCHEDULES ═══
[HttpGet("staff/schedules")]
public async Task<IActionResult> GetStaffSchedules([FromQuery] Guid? shopId = null)
{
try
{
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
var sql = @"SELECT id, staff_id, shop_id, day_of_week, start_time, end_time FROM staff_schedules";
if (shopId.HasValue) sql += " WHERE shop_id = @ShopId";
sql += " ORDER BY day_of_week, start_time";
var schedules = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
// EN: Enrich with staff names / VI: Bổ sung tên nhân viên
await using var mConn = new NpgsqlConnection(ConnStr("merchant_service"));
var staffList = (await mConn.QueryAsync<dynamic>(
"SELECT ms.id, ms.employee_code, ms.phone, sr.name as role FROM merchant_staff ms JOIN staff_roles sr ON ms.role_id = sr.id")).ToList();
var staffMap = staffList.ToDictionary(s => (Guid)s.id, s => new { code = (string?)s.employee_code, role = (string)s.role, phone = (string?)s.phone });
var result = schedules.Select(s => new {
s.id, s.staff_id, s.shop_id, s.day_of_week, s.start_time, s.end_time,
employee_code = staffMap.TryGetValue((Guid)s.staff_id, out var info) ? info.code : null,
role = info?.role, phone = info?.phone
});
return Ok(result);
}
catch { return Ok(Array.Empty<object>()); }
}
// ═══ ORDERS SUMMARY ═══
[HttpGet("orders")]
public async Task<IActionResult> GetOrders([FromQuery] Guid? shopId = null)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("order_service"));
var orders = await conn.QueryAsync<dynamic>(
@"SELECT o.id, o.shop_id, o.total_amount, o.status_id, o.created_at,
os.name as status
FROM orders o
JOIN order_statuses os ON o.status_id = os.id
WHERE o.shop_id = ANY(@ShopIds)
ORDER BY o.created_at DESC LIMIT 200",
new { ShopIds = targetShopIds.ToArray() });
return Ok(orders);
}
// ═══ WALLET/FINANCE (scoped by merchant owner_id) ═══
[HttpGet("wallets")]
public async Task<IActionResult> GetWallets()
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
try
{
await using var conn = new NpgsqlConnection(ConnStr("wallet_service"));
var wallets = await conn.QueryAsync<dynamic>(
@"SELECT w.id, w.balance, w.currency, w.owner_id, w.created_at,
(SELECT COALESCE(SUM(amount),0) FROM wallet_transactions wt WHERE wt.wallet_id = w.id AND wt.amount > 0) as total_income,
(SELECT COALESCE(SUM(ABS(amount)),0) FROM wallet_transactions wt WHERE wt.wallet_id = w.id AND wt.amount < 0) as total_expense
FROM wallets w
WHERE w.owner_id = @MerchantId::text
ORDER BY w.created_at DESC",
new { MerchantId = merchantId });
return Ok(wallets);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[BFF] GetWallets error: {ex.Message}");
return Ok(Array.Empty<object>());
}
}
[HttpGet("wallet/transactions")]
public async Task<IActionResult> GetWalletTransactions([FromQuery] int limit = 50)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
try
{
await using var conn = new NpgsqlConnection(ConnStr("wallet_service"));
var txns = await conn.QueryAsync<dynamic>(
@"SELECT wt.id, wt.wallet_id, wt.amount, wt.description, wt.created_at,
wi.name as item_name
FROM wallet_transactions wt
JOIN wallets w ON wt.wallet_id = w.id
LEFT JOIN wallet_items wi ON wt.reference_id = wi.id
WHERE w.owner_id = @MerchantId::text
ORDER BY wt.created_at DESC LIMIT @Limit",
new { MerchantId = merchantId, Limit = limit });
return Ok(txns);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[BFF] GetWalletTransactions error: {ex.Message}");
return Ok(Array.Empty<object>());
}
}
// ═══ DEVICES ═══
[HttpGet("devices")]
public async Task<IActionResult> GetDevices()
{
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var devices = await conn.QueryAsync<dynamic>(
@"SELECT dt.id, dt.device_token, dt.platform, dt.is_active, dt.created_at,
ms.employee_code as staff_code
FROM device_tokens dt
LEFT JOIN merchant_staff ms ON dt.staff_id = ms.id
ORDER BY dt.created_at DESC");
return Ok(devices);
}
// ═══ PROMOTIONS ═══
[HttpGet("promotions")]
public async Task<IActionResult> GetPromotions()
{
try
{
await using var conn = new NpgsqlConnection(ConnStr("promotion_service"));
var promos = await conn.QueryAsync<dynamic>(
@"SELECT c.id, c.name, c.description, c.start_date, c.end_date, c.is_active, c.discount_type, c.discount_value,
(SELECT COUNT(*) FROM vouchers v WHERE v.campaign_id = c.id) as voucher_count,
(SELECT COUNT(*) FROM redemptions r WHERE r.campaign_id = c.id) as redemption_count
FROM campaigns c ORDER BY c.created_at DESC");
return Ok(promos);
}
catch { return Ok(Array.Empty<object>()); }
}
// ═══ INVENTORY TRANSACTIONS ═══
[HttpGet("inventory/transactions")]
public async Task<IActionResult> GetInventoryTransactions([FromQuery] Guid? shopId = null)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("inventory_service"));
var txns = await conn.QueryAsync<dynamic>(
@"SELECT it.id, it.inventory_item_id, it.quantity_change, it.reason, it.created_at,
tt.name as transaction_type
FROM inventory_transactions it
JOIN transaction_types tt ON it.type_id = tt.id
JOIN inventory_items ii ON it.inventory_item_id = ii.id
WHERE ii.shop_id = ANY(@ShopIds)
ORDER BY it.created_at DESC LIMIT 100",
new { ShopIds = targetShopIds.ToArray() });
return Ok(txns);
}
// ═══ MEMBERSHIP LEVELS ═══
[HttpGet("membership/levels")]
public async Task<IActionResult> GetMembershipLevels()
{
try
{
await using var conn = new NpgsqlConnection(ConnStr("membership_service"));
var levels = await conn.QueryAsync<dynamic>(
@"SELECT ld.id, ld.level, ld.name, ld.min_exp, ld.max_exp,
(SELECT COUNT(*) FROM members m WHERE m.current_level = ld.level) as member_count
FROM level_definitions ld ORDER BY ld.level");
return Ok(levels);
}
catch { return Ok(Array.Empty<object>()); }
}
// ═══ SHOP STATS (merchant-scoped per-shop counts) ═══
/// <summary>
/// EN: Get aggregated stats per shop — scoped to current merchant.
/// VI: Lấy thống kê tổng hợp theo shop — lọc theo merchant hiện tại.
/// </summary>
[HttpGet("shops/stats")]
public async Task<IActionResult> GetShopStats()
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
var shopIdsArray = myShopIds.ToArray();
// Products per shop (scoped)
Dictionary<Guid, int> productCounts = new();
try
{
await using var catConn = new NpgsqlConnection(ConnStr("catalog_service"));
var prodStats = await catConn.QueryAsync<dynamic>(
"SELECT shop_id, COUNT(*) as cnt FROM products WHERE is_active = true AND shop_id = ANY(@ShopIds) GROUP BY shop_id",
new { ShopIds = shopIdsArray });
foreach (var ps in prodStats)
productCounts[(Guid)ps.shop_id] = (int)(long)ps.cnt;
}
catch { /* catalog_service may not have data yet */ }
// Orders per shop + revenue (scoped)
Dictionary<Guid, int> orderCounts = new();
Dictionary<Guid, decimal> revenues = new();
try
{
await using var orderConn = new NpgsqlConnection(ConnStr("order_service"));
var orderStats = await orderConn.QueryAsync<dynamic>(
"SELECT shop_id, COUNT(*) as cnt, COALESCE(SUM(total_amount), 0) as revenue FROM orders WHERE shop_id = ANY(@ShopIds) GROUP BY shop_id",
new { ShopIds = shopIdsArray });
foreach (var os in orderStats)
{
orderCounts[(Guid)os.shop_id] = (int)(long)os.cnt;
revenues[(Guid)os.shop_id] = (decimal)os.revenue;
}
}
catch { /* order_service may not have data yet */ }
// Staff per shop (scoped)
Dictionary<Guid, int> staffCounts = new();
try
{
await using var mConn = new NpgsqlConnection(ConnStr("merchant_service"));
var staffStats = await mConn.QueryAsync<dynamic>(
@"SELECT sm.shop_id, COUNT(DISTINCT sm.staff_id) as cnt
FROM shop_members sm
JOIN merchant_staff ms ON sm.staff_id = ms.id
JOIN staff_statuses ss ON ms.status_id = ss.id
WHERE ss.name = 'Active' AND sm.shop_id = ANY(@ShopIds)
GROUP BY sm.shop_id",
new { ShopIds = shopIdsArray });
foreach (var ss in staffStats)
staffCounts[(Guid)ss.shop_id] = (int)(long)ss.cnt;
}
catch { /* merchant_service may not have data yet */ }
var result = myShopIds.Select(shopId => new
{
shop_id = shopId,
product_count = productCounts.GetValueOrDefault(shopId, 0),
order_count = orderCounts.GetValueOrDefault(shopId, 0),
staff_count = staffCounts.GetValueOrDefault(shopId, 0),
revenue = revenues.GetValueOrDefault(shopId, 0m)
});
return Ok(result);
}
// EN: Request DTOs / VI: DTO yêu cầu
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, string? Type, string? Sku, string? ImageUrl);
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
}