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 ═══ /// /// 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. /// 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; } /// /// 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. /// private async Task GetCurrentMerchantIdAsync() { var userId = GetUserIdFromToken(); if (userId == null) return null; await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); return await conn.QueryFirstOrDefaultAsync( "SELECT id FROM merchants WHERE user_id = @UserId AND is_deleted = false", new { UserId = userId }); } /// /// 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. /// private async Task> GetMyShopIdsAsync(Guid merchantId) { await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); var ids = await conn.QueryAsync( "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 GetShops() { var merchantId = await GetCurrentMerchantIdAsync(); if (merchantId == null) return Ok(Array.Empty()); // 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( @"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 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( @"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 GetStaff() { var merchantId = await GetCurrentMerchantIdAsync(); if (merchantId == null) return Ok(Array.Empty()); await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); var staff = await conn.QueryAsync( @"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 GetProducts(Guid shopId) { await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); var products = await conn.QueryAsync( @"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 GetCategories(Guid shopId) { await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); var categories = await conn.QueryAsync( @"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 GetTables(Guid shopId) { await using var conn = new NpgsqlConnection(ConnStr("fnb_engine")); var tables = await conn.QueryAsync( @"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 GetAppointments(Guid shopId) { await using var conn = new NpgsqlConnection(ConnStr("booking_service")); var appointments = await conn.QueryAsync( @"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 GetResources(Guid shopId) { await using var conn = new NpgsqlConnection(ConnStr("booking_service")); var resources = await conn.QueryAsync( @"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 ═══ /// /// 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. /// [HttpGet("products")] public async Task GetAllProducts([FromQuery] Guid? shopId = null) { var merchantId = await GetCurrentMerchantIdAsync(); if (merchantId == null) return Ok(Array.Empty()); var myShopIds = await GetMyShopIdsAsync(merchantId.Value); if (!myShopIds.Any()) return Ok(Array.Empty()); // 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()); var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); var products = await conn.QueryAsync( @"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); } /// /// 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. /// [HttpGet("categories")] public async Task GetAllCategories([FromQuery] Guid? shopId = null) { var merchantId = await GetCurrentMerchantIdAsync(); if (merchantId == null) return Ok(Array.Empty()); var myShopIds = await GetMyShopIdsAsync(merchantId.Value); if (!myShopIds.Any()) return Ok(Array.Empty()); if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) return Ok(Array.Empty()); var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); var categories = await conn.QueryAsync( @"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); } /// /// 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. /// [HttpPost("products")] public async Task 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 }); } /// /// 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. /// [HttpDelete("products/{productId:guid}")] public async Task 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 ═══ /// /// 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. /// [HttpGet("inventory")] public async Task GetInventory([FromQuery] Guid? shopId = null) { var merchantId = await GetCurrentMerchantIdAsync(); if (merchantId == null) return Ok(Array.Empty()); var myShopIds = await GetMyShopIdsAsync(merchantId.Value); if (!myShopIds.Any()) return Ok(Array.Empty()); if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) return Ok(Array.Empty()); var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; await using var conn = new NpgsqlConnection(ConnStr("inventory_service")); var items = await conn.QueryAsync( @"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( "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 ═══ /// /// EN: Get all members (customers). /// VI: Lấy danh sách thành viên (khách hàng). /// [HttpGet("members")] public async Task GetMembers() { try { await using var conn = new NpgsqlConnection(ConnStr("membership_service")); var members = await conn.QueryAsync( @"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()); } } // ═══ STAFF CREATE ENDPOINT ═══ /// /// 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. /// [HttpPost("staff")] public async Task 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( "SELECT id FROM staff_roles WHERE name = @Role", new { req.Role }) ; if (roleId == 0) roleId = 1; var statusId = await conn.QueryFirstOrDefaultAsync( "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 GetStaffRoles() { try { await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); var roles = await conn.QueryAsync("SELECT id, name FROM staff_roles ORDER BY id"); return Ok(roles); } catch { return Ok(Array.Empty()); } } // ═══ STAFF SCHEDULES ═══ [HttpGet("staff/schedules")] public async Task 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(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( "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()); } } // ═══ ORDERS SUMMARY ═══ [HttpGet("orders")] public async Task GetOrders([FromQuery] Guid? shopId = null) { var merchantId = await GetCurrentMerchantIdAsync(); if (merchantId == null) return Ok(Array.Empty()); var myShopIds = await GetMyShopIdsAsync(merchantId.Value); if (!myShopIds.Any()) return Ok(Array.Empty()); if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) return Ok(Array.Empty()); var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; await using var conn = new NpgsqlConnection(ConnStr("order_service")); var orders = await conn.QueryAsync( @"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 GetWallets() { var merchantId = await GetCurrentMerchantIdAsync(); if (merchantId == null) return Ok(Array.Empty()); try { await using var conn = new NpgsqlConnection(ConnStr("wallet_service")); var wallets = await conn.QueryAsync( @"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()); } } [HttpGet("wallet/transactions")] public async Task GetWalletTransactions([FromQuery] int limit = 50) { var merchantId = await GetCurrentMerchantIdAsync(); if (merchantId == null) return Ok(Array.Empty()); try { await using var conn = new NpgsqlConnection(ConnStr("wallet_service")); var txns = await conn.QueryAsync( @"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()); } } // ═══ DEVICES ═══ [HttpGet("devices")] public async Task GetDevices() { await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); var devices = await conn.QueryAsync( @"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 GetPromotions() { try { await using var conn = new NpgsqlConnection(ConnStr("promotion_service")); var promos = await conn.QueryAsync( @"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()); } } // ═══ INVENTORY TRANSACTIONS ═══ [HttpGet("inventory/transactions")] public async Task GetInventoryTransactions([FromQuery] Guid? shopId = null) { var merchantId = await GetCurrentMerchantIdAsync(); if (merchantId == null) return Ok(Array.Empty()); var myShopIds = await GetMyShopIdsAsync(merchantId.Value); if (!myShopIds.Any()) return Ok(Array.Empty()); if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) return Ok(Array.Empty()); var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; await using var conn = new NpgsqlConnection(ConnStr("inventory_service")); var txns = await conn.QueryAsync( @"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 GetMembershipLevels() { try { await using var conn = new NpgsqlConnection(ConnStr("membership_service")); var levels = await conn.QueryAsync( @"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()); } } // ═══ SHOP STATS (merchant-scoped per-shop counts) ═══ /// /// 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. /// [HttpGet("shops/stats")] public async Task GetShopStats() { var merchantId = await GetCurrentMerchantIdAsync(); if (merchantId == null) return Ok(Array.Empty()); var myShopIds = await GetMyShopIdsAsync(merchantId.Value); if (!myShopIds.Any()) return Ok(Array.Empty()); var shopIdsArray = myShopIds.ToArray(); // Products per shop (scoped) Dictionary productCounts = new(); try { await using var catConn = new NpgsqlConnection(ConnStr("catalog_service")); var prodStats = await catConn.QueryAsync( "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 orderCounts = new(); Dictionary revenues = new(); try { await using var orderConn = new NpgsqlConnection(ConnStr("order_service")); var orderStats = await orderConn.QueryAsync( "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 staffCounts = new(); try { await using var mConn = new NpgsqlConnection(ConnStr("merchant_service")); var staffStats = await mConn.QueryAsync( @"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); }