From 0a5e1a927154eff300f9eef708325f9d2f24b651 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 4 Mar 2026 09:36:57 +0700 Subject: [PATCH] refactor(web-client-tpos): split monolithic BffDataController into 10 module controllers - Extract shared infrastructure: BffDbConnectionFactory, TenantContext (per-request cache) - Extract 19 DTOs to Models/BffDtos.cs - Create 10 controllers: Shop, Catalog, Staff, Order, Inventory, Financial, Booking, Fnb, Reports, Membership - Register DI services in Program.cs - Delete monolithic BffDataController.cs (1831 lines) - All API routes preserved under api/bff prefix (zero breaking changes) --- .../Controllers/BffDataController.cs | 1830 ----------------- .../Controllers/BookingController.cs | 172 ++ .../Controllers/CatalogController.cs | 267 +++ .../Controllers/FinancialController.cs | 179 ++ .../Controllers/FnbController.cs | 213 ++ .../Controllers/InventoryController.cs | 120 ++ .../Controllers/MembershipController.cs | 113 + .../Controllers/OrderController.cs | 351 ++++ .../Controllers/ReportsController.cs | 105 + .../Controllers/ShopController.cs | 259 +++ .../Controllers/StaffController.cs | 227 ++ .../Infrastructure/BffDbConnectionFactory.cs | 34 + .../Infrastructure/TenantContext.cs | 135 ++ .../WebClientTpos.Server/Models/BffDtos.cs | 51 + .../src/WebClientTpos.Server/Program.cs | 6 + 15 files changed, 2232 insertions(+), 1830 deletions(-) delete mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/CatalogController.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FinancialController.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/InventoryController.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/MembershipController.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/BffDbConnectionFactory.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/TenantContext.cs create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Models/BffDtos.cs diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs deleted file mode 100644 index c81c442f..00000000 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs +++ /dev/null @@ -1,1830 +0,0 @@ -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: Update a product — validates shop ownership first. - /// VI: Cập nhật sản phẩm — kiểm tra quyền sở hữu shop trước. - /// - [HttpPut("products/{productId:guid}")] - public async Task UpdateProduct(Guid productId, [FromBody] CreateProductRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) - return Unauthorized(); - - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - if (!myShopIds.Contains(req.ShopId)) - return Unauthorized(); - - var typeId = (req.Type ?? "PreparedFood") switch - { - "Physical" => 1, - "Service" => 2, - "PreparedFood" => 3, - _ => 3 - }; - await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); - var rows = await conn.ExecuteAsync( - @"UPDATE products SET name = @Name, description = @Description, price = @Price, - type_id = @TypeId, sku = @Sku, image_url = @ImageUrl - WHERE id = @Id AND shop_id = ANY(@ShopIds)", - new { Id = productId, req.Name, req.Description, req.Price, TypeId = typeId, - req.Sku, req.ImageUrl, ShopIds = myShopIds.ToArray() }); - return rows > 0 ? Ok(new { id = productId }) : NotFound(); - } - - /// - /// 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 Unauthorized(); // 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, permissions, user_id, joined_at, created_at) - VALUES (@Id, @MerchantId, @EmployeeCode, @Phone, @Email, @RoleId, @StatusId, 0, @UserId, NOW(), NOW())", - new { Id = id, req.MerchantId, req.EmployeeCode, req.Phone, req.Email, RoleId = roleId, StatusId = statusId, UserId = Guid.Empty }); - return CreatedAtAction(nameof(GetStaff), new { }, new { id }); - } - - /// - /// EN: Update a staff member — validates merchant ownership. - /// VI: Cập nhật nhân viên — kiểm tra quyền sở hữu merchant. - /// - [HttpPut("staff/{staffId:guid}")] - public async Task UpdateStaff(Guid staffId, [FromBody] CreateStaffRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null || merchantId.Value != req.MerchantId) - return Unauthorized(); - - 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 rows = await conn.ExecuteAsync( - @"UPDATE merchant_staff SET employee_code = @EmployeeCode, phone = @Phone, - email = @Email, role_id = @RoleId - WHERE id = @Id AND merchant_id = @MerchantId", - new { Id = staffId, req.MerchantId, req.EmployeeCode, req.Phone, req.Email, RoleId = roleId }); - return rows > 0 ? Ok(new { id = staffId }) : NotFound(); - } - - /// - /// EN: Terminate (soft-delete) a staff member. - /// VI: Chấm dứt (xóa mềm) nhân viên. - /// - [HttpDelete("staff/{staffId:guid}")] - public async Task DeleteStaff(Guid staffId) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - - await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); - // EN: Set status to Terminated + record termination date - var terminatedStatusId = await conn.QueryFirstOrDefaultAsync( - "SELECT id FROM staff_statuses WHERE name = 'Terminated'"); - if (terminatedStatusId == 0) terminatedStatusId = 3; - - await conn.ExecuteAsync( - @"UPDATE merchant_staff SET status_id = @StatusId, terminated_at = NOW() - WHERE id = @Id AND merchant_id = @MerchantId", - new { Id = staffId, StatusId = terminatedStatusId, MerchantId = merchantId.Value }); - return NoContent(); - } - - /// - /// EN: Update inventory quantity for a specific item. - /// VI: Cập nhật số lượng tồn kho cho mặt hàng. - /// - [HttpPut("inventory/{inventoryId:guid}")] - public async Task UpdateInventory(Guid inventoryId, [FromBody] UpdateInventoryRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("inventory_service")); - var rows = await conn.ExecuteAsync( - @"UPDATE inventory_items SET quantity = @Quantity, reorder_level = @ReorderLevel, updated_at = NOW() - WHERE id = @Id AND shop_id = ANY(@ShopIds)", - new { Id = inventoryId, req.Quantity, req.ReorderLevel, ShopIds = myShopIds.ToArray() }); - return rows > 0 ? Ok(new { id = inventoryId }) : NotFound(); - } - - // ═══ 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, - [FromQuery] string? filter = "today") - { - 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; - - // EN: Calculate date range based on filter / VI: Tính khoảng ngày theo filter - var dateCondition = filter switch - { - "week" => "AND o.created_at >= (CURRENT_DATE - INTERVAL '7 days')", - "month" => "AND o.created_at >= (CURRENT_DATE - INTERVAL '30 days')", - _ => "AND DATE(o.created_at) = CURRENT_DATE" // "today" default - }; - - 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, COALESCE(o.payment_method, 'cash') as payment_method, - o.notes - FROM orders o - JOIN order_statuses os ON o.status_id = os.id - WHERE o.shop_id = ANY(@ShopIds) {dateCondition} - 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 / CAMPAIGNS ═══ - - /// - /// EN: Get campaigns for current merchant — scoped by merchant_id. - /// VI: Lấy danh sách chiến dịch của merchant hiện tại — lọc theo merchant_id. - /// - [HttpGet("promotions")] - public async Task GetPromotions() - { - try - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Ok(Array.Empty()); - - await using var conn = new NpgsqlConnection(ConnStr("promotion_service")); - var campaigns = await conn.QueryAsync( - @"SELECT id, name, description, face_value, total_vouchers, issued_vouchers, - start_date, end_date, status_id, created_at - FROM campaigns - WHERE merchant_id = @MerchantId - ORDER BY created_at DESC", - new { MerchantId = merchantId }); - return Ok(campaigns); - } - catch { return Ok(Array.Empty()); } - } - - // ═══ CAMPAIGNS CRUD ═══ - - /// - /// EN: Create a campaign — validates merchant ownership. - /// VI: Tạo chiến dịch — kiểm tra quyền sở hữu merchant. - /// - [HttpPost("campaigns")] - public async Task CreateCampaign([FromBody] CreateCampaignRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - - var id = Guid.NewGuid(); - var now = DateTime.UtcNow; - await using var conn = new NpgsqlConnection(ConnStr("promotion_service")); - await conn.ExecuteAsync( - @"INSERT INTO campaigns (id, merchant_id, name, description, face_value, total_vouchers, issued_vouchers, - start_date, end_date, status_id, created_at, updated_at, - backing_asset_type_id, backing_asset_code, acquisition_type_id, acquisition_price, - escrow_amount, max_per_user, voucher_validity_days) - VALUES (@Id, @MerchantId, @Name, @Description, @FaceValue, @TotalVouchers, 0, - @StartDate, @EndDate, 1, @Now, @Now, - 1, 'VND', 1, 0, 0, 1, 30)", - new { Id = id, MerchantId = merchantId, req.Name, req.Description, req.FaceValue, - req.TotalVouchers, req.StartDate, req.EndDate, Now = now }); - return StatusCode(201, new { id }); - } - - /// - /// EN: Update a campaign — validates merchant ownership. - /// VI: Cập nhật chiến dịch — kiểm tra quyền sở hữu merchant. - /// - [HttpPut("campaigns/{campaignId:guid}")] - public async Task UpdateCampaign(Guid campaignId, [FromBody] CreateCampaignRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - - await using var conn = new NpgsqlConnection(ConnStr("promotion_service")); - var rows = await conn.ExecuteAsync( - @"UPDATE campaigns SET name=@Name, description=@Description, face_value=@FaceValue, - total_vouchers=@TotalVouchers, start_date=@StartDate, end_date=@EndDate, - updated_at=NOW() - WHERE id=@Id AND merchant_id=@MerchantId", - new { Id = campaignId, MerchantId = merchantId, req.Name, req.Description, - req.FaceValue, req.TotalVouchers, req.StartDate, req.EndDate }); - return rows > 0 ? Ok(new { id = campaignId }) : NotFound(); - } - - /// - /// EN: Disable a campaign (soft-delete by status_id=0) — validates merchant ownership. - /// VI: Vô hiệu hóa chiến dịch (soft-delete bằng status_id=0) — kiểm tra quyền sở hữu merchant. - /// - [HttpDelete("campaigns/{campaignId:guid}")] - public async Task DeleteCampaign(Guid campaignId) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - - await using var conn = new NpgsqlConnection(ConnStr("promotion_service")); - await conn.ExecuteAsync( - @"UPDATE campaigns SET status_id=0, updated_at=NOW() - WHERE id=@Id AND merchant_id=@MerchantId", - new { Id = campaignId, MerchantId = merchantId }); - return NoContent(); - } - - // ═══ MEMBERS CRUD ═══ - - /// - /// EN: Create a member — inserts with sensible defaults. - /// VI: Tạo thành viên — thêm với giá trị mặc định hợp lý. - /// - [HttpPost("members")] - public async Task CreateMember([FromBody] CreateMemberRequest req) - { - var id = Guid.NewGuid(); - var now = DateTime.UtcNow; - await using var conn = new NpgsqlConnection(ConnStr("membership_service")); - await conn.ExecuteAsync( - @"INSERT INTO members (id, country_code, current_exp, current_level, gender, - is_deleted, total_exp_earned, created_at, updated_at) - VALUES (@Id, @CountryCode, 0, 1, @Gender, false, 0, @Now, @Now)", - new { Id = id, CountryCode = req.CountryCode ?? "VN", req.Gender, Now = now }); - return StatusCode(201, new { id }); - } - - /// - /// EN: Update a member's gender and preferences. - /// VI: Cập nhật giới tính và tùy chọn cá nhân của thành viên. - /// - [HttpPut("members/{memberId:guid}")] - public async Task UpdateMember(Guid memberId, [FromBody] UpdateMemberRequest req) - { - await using var conn = new NpgsqlConnection(ConnStr("membership_service")); - var rows = await conn.ExecuteAsync( - @"UPDATE members SET gender=@Gender, preferences=@Preferences::jsonb, updated_at=NOW() - WHERE id=@Id AND is_deleted=false", - new { Id = memberId, req.Gender, Preferences = req.Preferences ?? "{}" }); - return rows > 0 ? Ok(new { id = memberId }) : NotFound(); - } - - /// - /// EN: Soft-delete a member. - /// VI: Xóa mềm thành viên. - /// - [HttpDelete("members/{memberId:guid}")] - public async Task DeleteMember(Guid memberId) - { - await using var conn = new NpgsqlConnection(ConnStr("membership_service")); - await conn.ExecuteAsync( - @"UPDATE members SET is_deleted=true, updated_at=NOW() WHERE id=@Id", - new { Id = memberId }); - return NoContent(); - } - - // ═══ 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); - } - - // ═══ POS DASHBOARD (real-time daily stats for POS screen) ═══ - - /// - /// EN: Get POS dashboard data — daily revenue, order count, popular items, payment breakdown, hourly chart. - /// VI: Lấy dữ liệu dashboard POS — doanh thu ngày, số đơn, món bán chạy, thanh toán, biểu đồ theo giờ. - /// - [HttpGet("pos/dashboard")] - public async Task GetPosDashboard([FromQuery] Guid? shopId = null) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) - return Ok(EmptyDashboard()); - - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - if (!myShopIds.Any()) - return Ok(EmptyDashboard()); - - if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) - return Ok(EmptyDashboard()); - - var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; - - // EN: Today's stats / VI: Thống kê hôm nay - decimal revenue = 0; int orderCount = 0; int itemsSold = 0; - List popularItems = new(); - List paymentBreakdown = new(); - List hourlyRevenue = new(); - List recentOrders = new(); - - try - { - await using var conn = new NpgsqlConnection(ConnStr("order_service")); - - // EN: Summary: total revenue + order count for today - // VI: Tổng kết: doanh thu + số đơn hôm nay - var summary = await conn.QueryFirstOrDefaultAsync( - @"SELECT COUNT(*) as cnt, COALESCE(SUM(total_amount), 0) as total - FROM orders WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE", - new { ShopIds = targetShopIds.ToArray() }); - if (summary != null) - { - orderCount = (int)(long)summary.cnt; - revenue = (decimal)summary.total; - } - - // EN: Recent orders (last 50 today) - // VI: Đơn hàng gần đây (50 đơn cuối hôm nay) - var orders = await conn.QueryAsync( - @"SELECT o.id, o.total_amount, o.created_at, os.name as status, - COALESCE(o.payment_method, 'cash') as payment_method - FROM orders o - JOIN order_statuses os ON o.status_id = os.id - WHERE o.shop_id = ANY(@ShopIds) AND DATE(o.created_at) = CURRENT_DATE - ORDER BY o.created_at DESC LIMIT 50", - new { ShopIds = targetShopIds.ToArray() }); - recentOrders = orders.Select(o => (object)new - { - id = ((Guid)o.id).ToString()[..8].ToUpper(), - total = (decimal)o.total_amount, - time = ((DateTime)o.created_at).ToString("HH:mm"), - status = (string)o.status, - method = MapPaymentMethod((string)o.payment_method) - }).ToList(); - - // EN: Payment breakdown (today) - // VI: Phân loại thanh toán (hôm nay) - try - { - var payments = await conn.QueryAsync( - @"SELECT COALESCE(payment_method, 'cash') as method, - SUM(total_amount) as total, COUNT(*) as cnt - FROM orders - WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE - GROUP BY COALESCE(payment_method, 'cash') - ORDER BY total DESC", - new { ShopIds = targetShopIds.ToArray() }); - var totalRev = payments.Sum(p => (decimal)p.total); - paymentBreakdown = payments.Select(p => (object)new - { - method = MapPaymentMethod((string)p.method), - amount = (decimal)p.total, - pct = totalRev > 0 ? (int)Math.Round((decimal)p.total / totalRev * 100) : 0 - }).ToList(); - } - catch { /* payment_method column may not exist */ } - - // EN: Hourly revenue (today, 6am–11pm) - // VI: Doanh thu theo giờ (hôm nay, 6h–23h) - try - { - var hourly = await conn.QueryAsync( - @"SELECT EXTRACT(HOUR FROM created_at)::int as hr, - SUM(total_amount) as total - FROM orders - WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE - GROUP BY 1 ORDER BY 1", - new { ShopIds = targetShopIds.ToArray() }); - var maxHr = hourly.Any() ? hourly.Max(h => (decimal)h.total) : 1; - for (int h = 6; h <= 22; h++) - { - var match = hourly.FirstOrDefault(x => (int)x.hr == h); - var val = match != null ? (decimal)match.total : 0; - hourlyRevenue.Add(new { hour = $"{h}h", revenue = val, pct = maxHr > 0 ? (int)(val / maxHr * 100) : 0 }); - } - } - catch { /* OK */ } - - // EN: Popular items (today — from order_items if exists) - // VI: Món bán chạy (hôm nay — từ order_items nếu có) - try - { - var popular = await conn.QueryAsync( - @"SELECT oi.product_name as name, SUM(oi.quantity) as qty, - SUM(oi.quantity * oi.unit_price) as revenue - FROM order_items oi - JOIN orders o ON oi.order_id = o.id - WHERE o.shop_id = ANY(@ShopIds) AND DATE(o.created_at) = CURRENT_DATE - GROUP BY oi.product_name - ORDER BY qty DESC LIMIT 10", - new { ShopIds = targetShopIds.ToArray() }); - itemsSold = (int)popular.Sum(p => (long)p.qty); - popularItems = popular.Select(p => (object)new - { - name = (string)p.name, - qty = (int)(long)p.qty, - revenue = (decimal)p.revenue - }).ToList(); - } - catch { /* order_items table may not exist yet */ } - } - catch { /* order_service DB not available */ } - - return Ok(new - { - revenue, - orderCount, - itemsSold, - avgOrderValue = orderCount > 0 ? revenue / orderCount : 0, - popularItems, - paymentBreakdown, - hourlyRevenue, - recentOrders - }); - } - - private static object EmptyDashboard() => new - { - revenue = 0m, orderCount = 0, itemsSold = 0, avgOrderValue = 0m, - popularItems = Array.Empty(), - paymentBreakdown = Array.Empty(), - hourlyRevenue = Array.Empty(), - recentOrders = Array.Empty() - }; - - private static string MapPaymentMethod(string method) => method switch - { - "cash" => "Tiền mặt", - "card" => "Thẻ", - "qr" => "QR Code", - "transfer" => "Chuyển khoản", - "ewallet" => "Ví điện tử", - _ => method - }; - - // ═══ POS ORDER CREATE (create real order from POS checkout) ═══ - - /// - /// EN: Create a POS order — inserts order + order_items, marks as Paid+Completed. - /// VI: Tạo đơn POS — insert order + order_items, đánh dấu Đã thanh toán + Hoàn thành. - /// - [HttpPost("pos/orders")] - public async Task CreatePosOrder([FromBody] CreatePosOrderRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) - { - var userId = GetUserIdFromToken(); - Console.Error.WriteLine($"[BFF] CreatePosOrder: merchantId null. userId={userId}, hasAuth={Request.Headers.ContainsKey("Authorization")}"); - return Unauthorized(new { message = "Merchant not found", userId }); - } - - // EN: Verify shop ownership / VI: Xác nhận quyền sở hữu shop - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - if (!myShopIds.Contains(req.ShopId)) - { - Console.Error.WriteLine($"[BFF] CreatePosOrder: shop {req.ShopId} not owned by merchant {merchantId}. Owned: [{string.Join(", ", myShopIds)}]"); - return Unauthorized(new { message = "Shop not owned by current merchant" }); - } - - var orderId = Guid.NewGuid(); - var now = DateTime.UtcNow; - var totalAmount = req.Items.Sum(i => i.Quantity * i.UnitPrice); - var transactionId = $"POS-{now:yyyyMMdd}-{orderId.ToString()[..8].ToUpper()}"; - - try - { - await using var conn = new NpgsqlConnection(ConnStr("order_service")); - await conn.OpenAsync(); - await using var tx = await conn.BeginTransactionAsync(); - - // EN: Step 1 — Insert order (status_id=5 = Completed for POS instant payment) - // VI: Bước 1 — Insert order (status_id=5 = Hoàn thành cho thanh toán POS tức thì) - await conn.ExecuteAsync( - @"INSERT INTO orders (id, shop_id, status_id, total_amount, customer_id, notes, payment_method, created_at, updated_at) - VALUES (@Id, @ShopId, 5, @Total, @CustomerId, @Notes, @PaymentMethod, @Now, @Now)", - new - { - Id = orderId, - req.ShopId, - Total = totalAmount, - CustomerId = (Guid?)null, - Notes = $"POS Order | {transactionId}", - PaymentMethod = req.PaymentMethod ?? "cash", - Now = now - }, tx); - - // EN: Step 2 — Insert order items - // VI: Bước 2 — Insert các mục đơn hàng - foreach (var item in req.Items) - { - await conn.ExecuteAsync( - @"INSERT INTO order_items (id, order_id, product_id, product_name, product_type, quantity, unit_price, status) - VALUES (@Id, @OrderId, @ProductId, @ProductName, 'PreparedFood', @Quantity, @UnitPrice, 'Completed')", - new - { - Id = Guid.NewGuid(), - OrderId = orderId, - item.ProductId, - item.ProductName, - item.Quantity, - item.UnitPrice - }, tx); - } - - await tx.CommitAsync(); - - return Ok(new - { - orderId, - transactionId, - totalAmount, - status = "Completed", - createdAt = now - }); - } - catch (Exception ex) - { - Console.Error.WriteLine($"[BFF] CreatePosOrder error: {ex.Message}"); - return StatusCode(500, new { message = "Failed to create order", error = ex.Message }); - } - } - - // ═══ CATEGORIES CRUD ═══ - - /// - /// EN: Create a category — validates shop ownership. - /// VI: Tạo danh mục — kiểm tra quyền sở hữu shop. - /// - [HttpPost("categories")] - public async Task CreateCategory([FromBody] CreateCategoryRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - if (!myShopIds.Contains(req.ShopId)) - return Forbid(); - - var id = Guid.NewGuid(); - await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); - await conn.ExecuteAsync( - @"INSERT INTO categories (id, shop_id, name, description, display_order, is_active, created_at) - VALUES (@Id, @ShopId, @Name, @Description, @DisplayOrder, true, NOW())", - new { Id = id, req.ShopId, req.Name, req.Description, req.DisplayOrder }); - return CreatedAtAction(nameof(GetAllCategories), new { }, new { id }); - } - - /// - /// EN: Update a category — validates shop ownership. - /// VI: Cập nhật danh mục — kiểm tra quyền sở hữu shop. - /// - [HttpPut("categories/{categoryId:guid}")] - public async Task UpdateCategory(Guid categoryId, [FromBody] CreateCategoryRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); - var rows = await conn.ExecuteAsync( - @"UPDATE categories SET name=@Name, description=@Description, display_order=@DisplayOrder, updated_at=NOW() - WHERE id=@Id AND shop_id = ANY(@ShopIds)", - new { Id = categoryId, req.Name, req.Description, req.DisplayOrder, ShopIds = myShopIds.ToArray() }); - return rows > 0 ? Ok(new { id = categoryId }) : NotFound(); - } - - /// - /// EN: Soft-delete a category — validates shop ownership. - /// VI: Xóa mềm danh mục — kiểm tra quyền sở hữu shop. - /// - [HttpDelete("categories/{categoryId:guid}")] - public async Task DeleteCategory(Guid categoryId) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); - await conn.ExecuteAsync( - @"UPDATE categories SET is_active=false, updated_at=NOW() - WHERE id=@Id AND shop_id = ANY(@ShopIds)", - new { Id = categoryId, ShopIds = myShopIds.ToArray() }); - return NoContent(); - } - - // ═══ ORDER DETAIL & CANCEL ═══ - - /// - /// EN: Get full order detail with items — validates shop ownership. - /// VI: Lấy chi tiết đơn hàng kèm items — kiểm tra quyền sở hữu shop. - /// - [HttpGet("orders/{orderId:guid}")] - public async Task GetOrderDetail(Guid orderId) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("order_service")); - var order = await conn.QueryFirstOrDefaultAsync( - @"SELECT o.id, o.shop_id, o.total_amount, os.name as status, o.status_id, - COALESCE(o.payment_method, 'cash') as payment_method, o.notes, o.created_at - FROM orders o - JOIN order_statuses os ON o.status_id = os.id - WHERE o.id = @Id AND o.shop_id = ANY(@ShopIds)", - new { Id = orderId, ShopIds = myShopIds.ToArray() }); - - if (order == null) return NotFound(); - - var items = await conn.QueryAsync( - @"SELECT id, product_name, quantity, unit_price, (quantity * unit_price) as subtotal - FROM order_items - WHERE order_id = @OrderId", - new { OrderId = orderId }); - - return Ok(new { order, items }); - } - - /// - /// EN: Cancel an order — validates ownership; rejects completed/already-cancelled. - /// VI: Hủy đơn hàng — kiểm tra quyền sở hữu; từ chối nếu đã xong hoặc đã hủy. - /// - [HttpPut("orders/{orderId:guid}/cancel")] - public async Task CancelOrder(Guid orderId) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("order_service")); - var rows = await conn.ExecuteAsync( - @"UPDATE orders SET status_id=6, updated_at=NOW() - WHERE id=@Id AND shop_id = ANY(@ShopIds) AND status_id NOT IN (5,6)", - new { Id = orderId, ShopIds = myShopIds.ToArray() }); - return rows > 0 ? Ok(new { id = orderId }) : BadRequest(new { message = "Đơn hàng đã hoàn thành hoặc đã hủy." }); - } - - // ═══ SHOP UPDATE ═══ - - /// - /// EN: Update shop info — validates ownership. - /// VI: Cập nhật thông tin cửa hàng — kiểm tra quyền sở hữu. - /// - [HttpPut("shops/{shopId:guid}")] - public async Task UpdateShop(Guid shopId, [FromBody] UpdateShopRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - if (!myShopIds.Contains(shopId)) - return Forbid(); - - await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); - var rows = await conn.ExecuteAsync( - @"UPDATE shops SET name=@Name, phone=@Phone, email=@Email, description=@Description, - open_time=@OpenTime, close_time=@CloseTime, updated_at=NOW() - WHERE id=@ShopId AND id = ANY(@ShopIds)", - new { req.Name, req.Phone, req.Email, req.Description, - req.OpenTime, req.CloseTime, ShopId = shopId, ShopIds = myShopIds.ToArray() }); - return rows > 0 ? Ok(new { id = shopId }) : NotFound(); - } - - // ═══ REVENUE REPORT ═══ - - /// - /// EN: Get revenue report grouped by day/week/month — scoped to merchant's shops. - /// VI: Lấy báo cáo doanh thu theo ngày/tuần/tháng — lọc theo shops của merchant. - /// - [HttpGet("reports/revenue")] - public async Task GetRevenueReport( - [FromQuery] string period = "daily", - [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 sql = period switch - { - "weekly" => @"SELECT date_trunc('week', created_at)::date as period, COUNT(*) as order_count, COALESCE(SUM(total_amount),0) as revenue - FROM orders WHERE shop_id = ANY(@ShopIds) AND status_id IN (3,5) AND created_at >= CURRENT_DATE - INTERVAL '84 days' - GROUP BY 1 ORDER BY 1 DESC", - "monthly" => @"SELECT date_trunc('month', created_at)::date as period, COUNT(*) as order_count, COALESCE(SUM(total_amount),0) as revenue - FROM orders WHERE shop_id = ANY(@ShopIds) AND status_id IN (3,5) AND created_at >= CURRENT_DATE - INTERVAL '365 days' - GROUP BY 1 ORDER BY 1 DESC", - _ => @"SELECT DATE(created_at) as period, COUNT(*) as order_count, COALESCE(SUM(total_amount),0) as revenue - FROM orders WHERE shop_id = ANY(@ShopIds) AND status_id IN (3,5) AND created_at >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY DATE(created_at) ORDER BY period DESC" - }; - - try - { - var report = await conn.QueryAsync(sql, new { ShopIds = targetShopIds.ToArray() }); - return Ok(report); - } - catch { return Ok(Array.Empty()); } - } - - // ═══ C1: SHOP SETTINGS ═══ - - [HttpGet("shops/{shopId:guid}/settings")] - public async Task GetShopSettings(Guid shopId) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return NotFound(); - - await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); - var settings = await conn.QueryFirstOrDefaultAsync( - @"SELECT features_config::text as features_config, - open_time::text as open_time, - close_time::text as close_time, - open_days - FROM shops - WHERE id = @ShopId AND merchant_id = @MerchantId AND is_deleted = false", - new { ShopId = shopId, MerchantId = merchantId }); - - if (settings == null) return NotFound(); - return Ok(settings); - } - - [HttpPut("shops/{shopId:guid}/settings")] - public async Task UpdateShopSettings(Guid shopId, [FromBody] UpdateShopSettingsRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - if (!myShopIds.Contains(shopId)) return Forbid(); - - await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); - try - { - await conn.ExecuteAsync( - @"UPDATE shops - SET features_config = @FeaturesConfig::jsonb, - open_time = @OpenTime::time, - close_time = @CloseTime::time, - open_days = @OpenDays, - updated_at = NOW() - WHERE id = @ShopId AND id = ANY(@ShopIds)", - new - { - ShopId = shopId, - ShopIds = myShopIds.ToArray(), - FeaturesConfig = string.IsNullOrWhiteSpace(req.FeaturesConfig) ? "{}" : req.FeaturesConfig, - OpenTime = string.IsNullOrWhiteSpace(req.OpenTime) ? (object)DBNull.Value : req.OpenTime, - CloseTime= string.IsNullOrWhiteSpace(req.CloseTime) ? (object)DBNull.Value : req.CloseTime, - OpenDays = req.OpenDays - }); - return Ok(new { success = true }); - } - catch (Exception ex) { return BadRequest(new { error = ex.Message }); } - } - - // ═══ C2: TOP PRODUCTS ═══ - - [HttpGet("reports/top-products")] - public async Task GetTopProducts( - [FromQuery] Guid? shopId = null, - [FromQuery] int limit = 10) - { - 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")); - try - { - var rows = await conn.QueryAsync( - @"SELECT oi.product_name, - SUM(oi.quantity)::bigint AS total_sold, - SUM(oi.quantity * oi.unit_price)::numeric AS total_revenue - FROM order_items oi - JOIN orders o ON oi.order_id = o.id - WHERE o.shop_id = ANY(@ShopIds) - AND o.status_id IN (3, 5) - GROUP BY oi.product_name - ORDER BY total_sold DESC - LIMIT @Limit", - new { ShopIds = targetShopIds.ToArray(), Limit = limit }); - return Ok(rows); - } - catch { return Ok(Array.Empty()); } - } - - // ═══ TABLES CRUD (fnb_engine) ═══ - - [HttpPost("tables")] - public async Task CreateTable([FromBody] CreateTableRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - if (!myShopIds.Contains(req.ShopId)) return Forbid(); - var id = Guid.NewGuid(); - await using var conn = new NpgsqlConnection(ConnStr("fnb_engine")); - await conn.ExecuteAsync( - @"INSERT INTO tables (id, shop_id, table_number, capacity, zone, status_id, created_at, updated_at) - VALUES (@Id, @ShopId, @TableNumber, @Capacity, @Zone, 1, NOW(), NOW())", - new { Id = id, req.ShopId, req.TableNumber, req.Capacity, Zone = req.Zone ?? "" }); - return StatusCode(201, new { id }); - } - - [HttpPut("tables/{tableId:guid}")] - public async Task UpdateTable(Guid tableId, [FromBody] CreateTableRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("fnb_engine")); - var rows = await conn.ExecuteAsync( - @"UPDATE tables SET table_number=@TableNumber, capacity=@Capacity, zone=@Zone, updated_at=NOW() - WHERE id=@Id AND shop_id=ANY(@ShopIds)", - new { Id = tableId, req.TableNumber, req.Capacity, Zone = req.Zone ?? "", ShopIds = myShopIds.ToArray() }); - return rows > 0 ? Ok(new { id = tableId }) : NotFound(); - } - - [HttpDelete("tables/{tableId:guid}")] - public async Task DeleteTable(Guid tableId) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("fnb_engine")); - await conn.ExecuteAsync( - "DELETE FROM tables WHERE id=@Id AND shop_id=ANY(@ShopIds)", - new { Id = tableId, ShopIds = myShopIds.ToArray() }); - return NoContent(); - } - - // ═══ APPOINTMENTS CRUD (booking_service) ═══ - - [HttpPost("appointments")] - public async Task CreateAppointment([FromBody] CreateAppointmentRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - if (!myShopIds.Contains(req.ShopId)) return Forbid(); - var id = Guid.NewGuid(); - await using var conn = new NpgsqlConnection(ConnStr("booking_service")); - await conn.ExecuteAsync( - @"INSERT INTO appointments (id, shop_id, customer_id, staff_id, resource_id, service_id, start_time, end_time, status, created_at) - VALUES (@Id, @ShopId, @CustomerId, @StaffId, @ResourceId, @ServiceId, @StartTime, @EndTime, 'Scheduled', NOW())", - new { Id = id, req.ShopId, req.CustomerId, req.StaffId, req.ResourceId, req.ServiceId, req.StartTime, req.EndTime }); - return StatusCode(201, new { id }); - } - - [HttpPut("appointments/{apptId:guid}")] - public async Task UpdateAppointment(Guid apptId, [FromBody] CreateAppointmentRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("booking_service")); - var rows = await conn.ExecuteAsync( - @"UPDATE appointments SET start_time=@StartTime, end_time=@EndTime, staff_id=@StaffId, - resource_id=@ResourceId, status=@Status - WHERE id=@Id AND shop_id=ANY(@ShopIds)", - new { Id = apptId, req.StartTime, req.EndTime, req.StaffId, req.ResourceId, - Status = req.Status ?? "Scheduled", ShopIds = myShopIds.ToArray() }); - return rows > 0 ? Ok(new { id = apptId }) : NotFound(); - } - - [HttpDelete("appointments/{apptId:guid}/cancel")] - public async Task CancelAppointment(Guid apptId) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("booking_service")); - await conn.ExecuteAsync( - "UPDATE appointments SET status='Cancelled' WHERE id=@Id AND shop_id=ANY(@ShopIds)", - new { Id = apptId, ShopIds = myShopIds.ToArray() }); - return NoContent(); - } - - // ═══ RESOURCES CRUD (booking_service) ═══ - - [HttpPost("resources")] - public async Task CreateResource([FromBody] CreateResourceRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - if (!myShopIds.Contains(req.ShopId)) return Forbid(); - var id = Guid.NewGuid(); - await using var conn = new NpgsqlConnection(ConnStr("booking_service")); - await conn.ExecuteAsync( - @"INSERT INTO resources (id, shop_id, name, resource_type, capacity, is_active, created_at) - VALUES (@Id, @ShopId, @Name, @ResourceType, @Capacity, true, NOW())", - new { Id = id, req.ShopId, req.Name, req.ResourceType, req.Capacity }); - return StatusCode(201, new { id }); - } - - [HttpPut("resources/{resourceId:guid}")] - public async Task UpdateResource(Guid resourceId, [FromBody] CreateResourceRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("booking_service")); - var rows = await conn.ExecuteAsync( - "UPDATE resources SET name=@Name, resource_type=@ResourceType, capacity=@Capacity WHERE id=@Id AND shop_id=ANY(@ShopIds)", - new { Id = resourceId, req.Name, req.ResourceType, req.Capacity, ShopIds = myShopIds.ToArray() }); - return rows > 0 ? Ok(new { id = resourceId }) : NotFound(); - } - - [HttpDelete("resources/{resourceId:guid}")] - public async Task DeleteResource(Guid resourceId) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("booking_service")); - await conn.ExecuteAsync( - "UPDATE resources SET is_active=false WHERE id=@Id AND shop_id=ANY(@ShopIds)", - new { Id = resourceId, ShopIds = myShopIds.ToArray() }); - return NoContent(); - } - - // ═══ STAFF SCHEDULES CRUD (booking_service) ═══ - - [HttpPost("schedules")] - public async Task CreateSchedule([FromBody] CreateScheduleRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - if (!myShopIds.Contains(req.ShopId)) return Forbid(); - var id = Guid.NewGuid(); - await using var conn = new NpgsqlConnection(ConnStr("booking_service")); - await conn.ExecuteAsync( - @"INSERT INTO staff_schedules (id, shop_id, staff_id, day_of_week, start_time, end_time) - VALUES (@Id, @ShopId, @StaffId, @DayOfWeek, @StartTime::time, @EndTime::time)", - new { Id = id, req.ShopId, req.StaffId, req.DayOfWeek, req.StartTime, req.EndTime }); - return StatusCode(201, new { id }); - } - - [HttpPut("schedules/{scheduleId:guid}")] - public async Task UpdateSchedule(Guid scheduleId, [FromBody] CreateScheduleRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("booking_service")); - var rows = await conn.ExecuteAsync( - "UPDATE staff_schedules SET day_of_week=@DayOfWeek, start_time=@StartTime::time, end_time=@EndTime::time WHERE id=@Id AND shop_id=ANY(@ShopIds)", - new { Id = scheduleId, req.DayOfWeek, req.StartTime, req.EndTime, ShopIds = myShopIds.ToArray() }); - return rows > 0 ? Ok(new { id = scheduleId }) : NotFound(); - } - - [HttpDelete("schedules/{scheduleId:guid}")] - public async Task DeleteSchedule(Guid scheduleId) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("booking_service")); - await conn.ExecuteAsync( - "DELETE FROM staff_schedules WHERE id=@Id AND shop_id=ANY(@ShopIds)", - new { Id = scheduleId, ShopIds = myShopIds.ToArray() }); - return NoContent(); - } - - // ═══ KITCHEN TICKETS (fnb_engine) ═══ - - [HttpGet("shops/{shopId}/kitchen-tickets")] - public async Task GetKitchenTickets(Guid shopId, [FromQuery] string status = "pending") - { - 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 (!myShopIds.Contains(shopId)) return Ok(Array.Empty()); - var targetShopIds = new List { shopId }; - try - { - await using var conn = new NpgsqlConnection(ConnStr("fnb_engine")); - var whereStatus = status == "all" ? "" : "AND kt.status=@Status"; - var tickets = await conn.QueryAsync( - $@"SELECT kt.* FROM kitchen_tickets kt - JOIN sessions s ON kt.session_id = s.id - WHERE s.shop_id = ANY(@ShopIds) {whereStatus} - ORDER BY kt.priority DESC, kt.created_at", - new { ShopIds = targetShopIds.ToArray(), Status = status }); - return Ok(tickets); - } - catch { return Ok(Array.Empty()); } - } - - [HttpPut("kitchen/tickets/{ticketId:guid}/status")] - public async Task UpdateTicketStatus(Guid ticketId, [FromBody] UpdateTicketStatusRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - try - { - await using var conn = new NpgsqlConnection(ConnStr("fnb_engine")); - await conn.ExecuteAsync( - @"UPDATE kitchen_tickets SET status=@Status, - completed_at=CASE WHEN @Status='completed' THEN NOW() ELSE NULL END - WHERE id=@Id", - new { Id = ticketId, req.Status }); - return Ok(new { id = ticketId }); - } - catch (Exception ex) { return BadRequest(new { error = ex.Message }); } - } - - // ═══ RECIPES CRUD (catalog_service) ═══ - - [HttpGet("shops/{shopId}/recipes")] - public async Task GetRecipes(Guid shopId) - { - 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 (!myShopIds.Contains(shopId)) return Ok(Array.Empty()); - var targetShopIds = new List { shopId }; - try - { - await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); - var recipes = await conn.QueryAsync( - @"SELECT r.*, (SELECT json_agg(row_to_json(ri)) FROM recipe_ingredients ri WHERE ri.recipe_id = r.id) as ingredients - FROM recipes r WHERE r.shop_id = ANY(@ShopIds) AND r.is_active = true - ORDER BY r.name", - new { ShopIds = targetShopIds.ToArray() }); - return Ok(recipes); - } - catch { return Ok(Array.Empty()); } - } - - [HttpPost("recipes")] - public async Task CreateRecipe([FromBody] CreateRecipeRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - if (!myShopIds.Contains(req.ShopId)) return Forbid(); - var id = Guid.NewGuid(); - await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); - await conn.OpenAsync(); - await using var tx = await conn.BeginTransactionAsync(); - await conn.ExecuteAsync( - @"INSERT INTO recipes (id, product_id, shop_id, name, instructions, prep_time_minutes, is_active, created_at, updated_at) - VALUES (@Id, @ProductId, @ShopId, @Name, @Instructions, @PrepTimeMinutes, true, NOW(), NOW())", - new { Id = id, req.ProductId, req.ShopId, req.Name, req.Instructions, req.PrepTimeMinutes }, tx); - foreach (var ing in req.Ingredients ?? new()) - await conn.ExecuteAsync( - @"INSERT INTO recipe_ingredients (id, recipe_id, ingredient_name, quantity, unit, cost_per_unit, created_at) - VALUES (@Id, @RecipeId, @IngredientName, @Quantity, @Unit, @CostPerUnit, NOW())", - new { Id = Guid.NewGuid(), RecipeId = id, ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit }, tx); - await tx.CommitAsync(); - return StatusCode(201, new { id }); - } - - [HttpPut("recipes/{recipeId:guid}")] - public async Task UpdateRecipe(Guid recipeId, [FromBody] CreateRecipeRequest req) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Unauthorized(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); - await conn.OpenAsync(); - await using var tx = await conn.BeginTransactionAsync(); - await conn.ExecuteAsync( - "UPDATE recipes SET name=@Name, instructions=@Instructions, prep_time_minutes=@PrepTimeMinutes, updated_at=NOW() WHERE id=@Id AND shop_id=ANY(@ShopIds)", - new { Id = recipeId, req.Name, req.Instructions, req.PrepTimeMinutes, ShopIds = myShopIds.ToArray() }, tx); - await conn.ExecuteAsync("DELETE FROM recipe_ingredients WHERE recipe_id=@Id", new { Id = recipeId }, tx); - foreach (var ing in req.Ingredients ?? new()) - await conn.ExecuteAsync( - @"INSERT INTO recipe_ingredients (id, recipe_id, ingredient_name, quantity, unit, cost_per_unit, created_at) - VALUES (@Id, @RecipeId, @IngredientName, @Quantity, @Unit, @CostPerUnit, NOW())", - new { Id = Guid.NewGuid(), RecipeId = recipeId, ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit }, tx); - await tx.CommitAsync(); - return Ok(new { id = recipeId }); - } - - [HttpDelete("recipes/{recipeId:guid}")] - public async Task DeleteRecipe(Guid recipeId) - { - var merchantId = await GetCurrentMerchantIdAsync(); - if (merchantId == null) return Forbid(); - var myShopIds = await GetMyShopIdsAsync(merchantId.Value); - await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); - await conn.ExecuteAsync( - "UPDATE recipes SET is_active=false, updated_at=NOW() WHERE id=@Id AND shop_id=ANY(@ShopIds)", - new { Id = recipeId, ShopIds = myShopIds.ToArray() }); - return NoContent(); - } - - // 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); - public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List Items); - public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice); - public record UpdateInventoryRequest(int Quantity, int ReorderLevel); - public record CreateCategoryRequest(Guid ShopId, string Name, string? Description, int DisplayOrder); - public record UpdateShopRequest(string? Name, string? Phone, string? Email, string? Description, string? OpenTime, string? CloseTime, string? OpenDays); - public record CreateCampaignRequest(string Name, string? Description, decimal FaceValue, int TotalVouchers, DateTime StartDate, DateTime EndDate); - public record CreateMemberRequest(string? Gender, string? CountryCode); - public record UpdateMemberRequest(string? Gender, string? Preferences); - public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays); - public record TopProductItem(string ProductName, long TotalSold, decimal TotalRevenue); - public record CreateTableRequest(Guid ShopId, string TableNumber, int Capacity, string? Zone); - public record CreateAppointmentRequest(Guid ShopId, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid? ServiceId, DateTime StartTime, DateTime EndTime, string? Status = null); - public record CreateResourceRequest(Guid ShopId, string Name, string ResourceType, int Capacity); - public record CreateScheduleRequest(Guid ShopId, Guid StaffId, int DayOfWeek, string StartTime, string EndTime); - public record UpdateTicketStatusRequest(string Status); - public record CreateRecipeRequest(Guid ShopId, Guid ProductId, string Name, string? Instructions, int PrepTimeMinutes, List? Ingredients); - public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit); -} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs new file mode 100644 index 00000000..233c4995 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs @@ -0,0 +1,172 @@ +using Microsoft.AspNetCore.Mvc; +using Dapper; +using WebClientTpos.Server.Infrastructure; +using WebClientTpos.Server.Models; + +namespace WebClientTpos.Server.Controllers; + +/// +/// EN: Booking controller — CRUD for appointments and resources, scoped to merchant's shops. +/// VI: Controller đặt lịch — CRUD cho lịch hẹn và tài nguyên, lọc theo shops của merchant. +/// +[ApiController] +[Route("api/bff")] +public class BookingController : ControllerBase +{ + private readonly TenantContext _tenant; + private readonly BffDbConnectionFactory _dbFactory; + + public BookingController(TenantContext tenant, BffDbConnectionFactory dbFactory) + { + _tenant = tenant; + _dbFactory = dbFactory; + } + + /// + /// EN: Get appointments for a specific shop. + /// VI: Lấy lịch hẹn của một cửa hàng cụ thể. + /// + [HttpGet("shops/{shopId}/appointments")] + public async Task GetAppointments(Guid shopId) + { + await using var conn = _dbFactory.CreateConnection("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); + } + + /// + /// EN: Create an appointment — validates shop ownership. + /// VI: Tạo lịch hẹn — kiểm tra quyền sở hữu shop. + /// + [HttpPost("appointments")] + public async Task CreateAppointment([FromBody] CreateAppointmentRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Contains(req.ShopId)) return Forbid(); + var id = Guid.NewGuid(); + await using var conn = _dbFactory.CreateConnection("booking_service"); + await conn.ExecuteAsync( + @"INSERT INTO appointments (id, shop_id, customer_id, staff_id, resource_id, service_id, start_time, end_time, status, created_at) + VALUES (@Id, @ShopId, @CustomerId, @StaffId, @ResourceId, @ServiceId, @StartTime, @EndTime, 'Scheduled', NOW())", + new { Id = id, req.ShopId, req.CustomerId, req.StaffId, req.ResourceId, req.ServiceId, req.StartTime, req.EndTime }); + return StatusCode(201, new { id }); + } + + /// + /// EN: Update an appointment — validates shop ownership. + /// VI: Cập nhật lịch hẹn — kiểm tra quyền sở hữu shop. + /// + [HttpPut("appointments/{apptId:guid}")] + public async Task UpdateAppointment(Guid apptId, [FromBody] CreateAppointmentRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("booking_service"); + var rows = await conn.ExecuteAsync( + @"UPDATE appointments SET start_time=@StartTime, end_time=@EndTime, staff_id=@StaffId, + resource_id=@ResourceId, status=@Status + WHERE id=@Id AND shop_id=ANY(@ShopIds)", + new { Id = apptId, req.StartTime, req.EndTime, req.StaffId, req.ResourceId, + Status = req.Status ?? "Scheduled", ShopIds = myShopIds.ToArray() }); + return rows > 0 ? Ok(new { id = apptId }) : NotFound(); + } + + /// + /// EN: Cancel an appointment — validates shop ownership. + /// VI: Hủy lịch hẹn — kiểm tra quyền sở hữu shop. + /// + [HttpDelete("appointments/{apptId:guid}/cancel")] + public async Task CancelAppointment(Guid apptId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("booking_service"); + await conn.ExecuteAsync( + "UPDATE appointments SET status='Cancelled' WHERE id=@Id AND shop_id=ANY(@ShopIds)", + new { Id = apptId, ShopIds = myShopIds.ToArray() }); + return NoContent(); + } + + /// + /// EN: Get resources for a specific shop. + /// VI: Lấy tài nguyên của một cửa hàng cụ thể. + /// + [HttpGet("shops/{shopId}/resources")] + public async Task GetResources(Guid shopId) + { + await using var conn = _dbFactory.CreateConnection("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); + } + + /// + /// EN: Create a resource — validates shop ownership. + /// VI: Tạo tài nguyên — kiểm tra quyền sở hữu shop. + /// + [HttpPost("resources")] + public async Task CreateResource([FromBody] CreateResourceRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Contains(req.ShopId)) return Forbid(); + var id = Guid.NewGuid(); + await using var conn = _dbFactory.CreateConnection("booking_service"); + await conn.ExecuteAsync( + @"INSERT INTO resources (id, shop_id, name, resource_type, capacity, is_active, created_at) + VALUES (@Id, @ShopId, @Name, @ResourceType, @Capacity, true, NOW())", + new { Id = id, req.ShopId, req.Name, req.ResourceType, req.Capacity }); + return StatusCode(201, new { id }); + } + + /// + /// EN: Update a resource — validates shop ownership. + /// VI: Cập nhật tài nguyên — kiểm tra quyền sở hữu shop. + /// + [HttpPut("resources/{resourceId:guid}")] + public async Task UpdateResource(Guid resourceId, [FromBody] CreateResourceRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("booking_service"); + var rows = await conn.ExecuteAsync( + "UPDATE resources SET name=@Name, resource_type=@ResourceType, capacity=@Capacity WHERE id=@Id AND shop_id=ANY(@ShopIds)", + new { Id = resourceId, req.Name, req.ResourceType, req.Capacity, ShopIds = myShopIds.ToArray() }); + return rows > 0 ? Ok(new { id = resourceId }) : NotFound(); + } + + /// + /// EN: Soft-delete a resource — validates shop ownership. + /// VI: Xóa mềm tài nguyên — kiểm tra quyền sở hữu shop. + /// + [HttpDelete("resources/{resourceId:guid}")] + public async Task DeleteResource(Guid resourceId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("booking_service"); + await conn.ExecuteAsync( + "UPDATE resources SET is_active=false WHERE id=@Id AND shop_id=ANY(@ShopIds)", + new { Id = resourceId, ShopIds = myShopIds.ToArray() }); + return NoContent(); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/CatalogController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/CatalogController.cs new file mode 100644 index 00000000..11d4cee5 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/CatalogController.cs @@ -0,0 +1,267 @@ +using Microsoft.AspNetCore.Mvc; +using Dapper; +using WebClientTpos.Server.Infrastructure; +using WebClientTpos.Server.Models; + +namespace WebClientTpos.Server.Controllers; + +/// +/// EN: Catalog controller — CRUD for products and categories, scoped to merchant's shops. +/// VI: Controller danh mục — CRUD cho sản phẩm và danh mục, lọc theo shops của merchant. +/// +[ApiController] +[Route("api/bff")] +public class CatalogController : ControllerBase +{ + private readonly TenantContext _tenant; + private readonly BffDbConnectionFactory _dbFactory; + + public CatalogController(TenantContext tenant, BffDbConnectionFactory dbFactory) + { + _tenant = tenant; + _dbFactory = dbFactory; + } + + /// + /// 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 _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + var myShopIds = await _tenant.GetShopIdsAsync(); + 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 = _dbFactory.CreateConnection("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 products for a specific shop. + /// VI: Lấy sản phẩm của một cửa hàng cụ thể. + /// + [HttpGet("shops/{shopId}/products")] + public async Task GetShopProducts(Guid shopId) + { + await using var conn = _dbFactory.CreateConnection("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); + } + + /// + /// 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 _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Forbid(); + + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Contains(req.ShopId)) + return Forbid(); + + var id = Guid.NewGuid(); + var typeId = (req.Type ?? "PreparedFood") switch + { + "Physical" => 1, + "Service" => 2, + "PreparedFood" => 3, + _ => 3 + }; + await using var conn = _dbFactory.CreateConnection("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: Update a product — validates shop ownership first. + /// VI: Cập nhật sản phẩm — kiểm tra quyền sở hữu shop trước. + /// + [HttpPut("products/{productId:guid}")] + public async Task UpdateProduct(Guid productId, [FromBody] CreateProductRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Unauthorized(); + + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Contains(req.ShopId)) + return Unauthorized(); + + var typeId = (req.Type ?? "PreparedFood") switch + { + "Physical" => 1, + "Service" => 2, + "PreparedFood" => 3, + _ => 3 + }; + await using var conn = _dbFactory.CreateConnection("catalog_service"); + var rows = await conn.ExecuteAsync( + @"UPDATE products SET name = @Name, description = @Description, price = @Price, + type_id = @TypeId, sku = @Sku, image_url = @ImageUrl + WHERE id = @Id AND shop_id = ANY(@ShopIds)", + new { Id = productId, req.Name, req.Description, req.Price, TypeId = typeId, + req.Sku, req.ImageUrl, ShopIds = myShopIds.ToArray() }); + return rows > 0 ? Ok(new { id = productId }) : NotFound(); + } + + /// + /// 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 _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Forbid(); + + var myShopIds = await _tenant.GetShopIdsAsync(); + + await using var conn = _dbFactory.CreateConnection("catalog_service"); + 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(); + } + + /// + /// 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 _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + var myShopIds = await _tenant.GetShopIdsAsync(); + 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 = _dbFactory.CreateConnection("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: Get categories for a specific shop. + /// VI: Lấy danh mục của một cửa hàng cụ thể. + /// + [HttpGet("shops/{shopId}/categories")] + public async Task GetShopCategories(Guid shopId) + { + await using var conn = _dbFactory.CreateConnection("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); + } + + /// + /// EN: Create a category — validates shop ownership. + /// VI: Tạo danh mục — kiểm tra quyền sở hữu shop. + /// + [HttpPost("categories")] + public async Task CreateCategory([FromBody] CreateCategoryRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Contains(req.ShopId)) + return Forbid(); + + var id = Guid.NewGuid(); + await using var conn = _dbFactory.CreateConnection("catalog_service"); + await conn.ExecuteAsync( + @"INSERT INTO categories (id, shop_id, name, description, display_order, is_active, created_at) + VALUES (@Id, @ShopId, @Name, @Description, @DisplayOrder, true, NOW())", + new { Id = id, req.ShopId, req.Name, req.Description, req.DisplayOrder }); + return CreatedAtAction(nameof(GetAllCategories), new { }, new { id }); + } + + /// + /// EN: Update a category — validates shop ownership. + /// VI: Cập nhật danh mục — kiểm tra quyền sở hữu shop. + /// + [HttpPut("categories/{categoryId:guid}")] + public async Task UpdateCategory(Guid categoryId, [FromBody] CreateCategoryRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("catalog_service"); + var rows = await conn.ExecuteAsync( + @"UPDATE categories SET name=@Name, description=@Description, display_order=@DisplayOrder, updated_at=NOW() + WHERE id=@Id AND shop_id = ANY(@ShopIds)", + new { Id = categoryId, req.Name, req.Description, req.DisplayOrder, ShopIds = myShopIds.ToArray() }); + return rows > 0 ? Ok(new { id = categoryId }) : NotFound(); + } + + /// + /// EN: Soft-delete a category — validates shop ownership. + /// VI: Xóa mềm danh mục — kiểm tra quyền sở hữu shop. + /// + [HttpDelete("categories/{categoryId:guid}")] + public async Task DeleteCategory(Guid categoryId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("catalog_service"); + await conn.ExecuteAsync( + @"UPDATE categories SET is_active=false, updated_at=NOW() + WHERE id=@Id AND shop_id = ANY(@ShopIds)", + new { Id = categoryId, ShopIds = myShopIds.ToArray() }); + return NoContent(); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FinancialController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FinancialController.cs new file mode 100644 index 00000000..9b52714f --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FinancialController.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Mvc; +using Dapper; +using WebClientTpos.Server.Infrastructure; +using WebClientTpos.Server.Models; + +namespace WebClientTpos.Server.Controllers; + +/// +/// EN: Financial controller — wallets, wallet transactions, promotions, and campaigns. +/// Wallets are scoped by merchant owner_id; campaigns by merchant_id. +/// VI: Controller tài chính — ví, giao dịch ví, khuyến mãi và chiến dịch. +/// Ví lọc theo merchant owner_id; chiến dịch lọc theo merchant_id. +/// +[ApiController] +[Route("api/bff")] +public class FinancialController : ControllerBase +{ + private readonly TenantContext _tenant; + private readonly BffDbConnectionFactory _dbFactory; + + public FinancialController(TenantContext tenant, BffDbConnectionFactory dbFactory) + { + _tenant = tenant; + _dbFactory = dbFactory; + } + + /// + /// EN: Get wallets for the current merchant — scoped by owner_id. + /// VI: Lấy ví của merchant hiện tại — lọc theo owner_id. + /// + [HttpGet("wallets")] + public async Task GetWallets() + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + try + { + await using var conn = _dbFactory.CreateConnection("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()); + } + } + + /// + /// EN: Get wallet transactions for the current merchant. + /// VI: Lấy giao dịch ví của merchant hiện tại. + /// + [HttpGet("wallet/transactions")] + public async Task GetWalletTransactions([FromQuery] int limit = 50) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + try + { + await using var conn = _dbFactory.CreateConnection("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()); + } + } + + /// + /// EN: Get campaigns for current merchant — scoped by merchant_id. + /// VI: Lấy danh sách chiến dịch của merchant hiện tại — lọc theo merchant_id. + /// + [HttpGet("promotions")] + public async Task GetPromotions() + { + try + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Ok(Array.Empty()); + + await using var conn = _dbFactory.CreateConnection("promotion_service"); + var campaigns = await conn.QueryAsync( + @"SELECT id, name, description, face_value, total_vouchers, issued_vouchers, + start_date, end_date, status_id, created_at + FROM campaigns + WHERE merchant_id = @MerchantId + ORDER BY created_at DESC", + new { MerchantId = merchantId }); + return Ok(campaigns); + } + catch { return Ok(Array.Empty()); } + } + + /// + /// EN: Create a campaign — validates merchant ownership. + /// VI: Tạo chiến dịch — kiểm tra quyền sở hữu merchant. + /// + [HttpPost("campaigns")] + public async Task CreateCampaign([FromBody] CreateCampaignRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var id = Guid.NewGuid(); + var now = DateTime.UtcNow; + await using var conn = _dbFactory.CreateConnection("promotion_service"); + await conn.ExecuteAsync( + @"INSERT INTO campaigns (id, merchant_id, name, description, face_value, total_vouchers, issued_vouchers, + start_date, end_date, status_id, created_at, updated_at, + backing_asset_type_id, backing_asset_code, acquisition_type_id, acquisition_price, + escrow_amount, max_per_user, voucher_validity_days) + VALUES (@Id, @MerchantId, @Name, @Description, @FaceValue, @TotalVouchers, 0, + @StartDate, @EndDate, 1, @Now, @Now, + 1, 'VND', 1, 0, 0, 1, 30)", + new { Id = id, MerchantId = merchantId, req.Name, req.Description, req.FaceValue, + req.TotalVouchers, req.StartDate, req.EndDate, Now = now }); + return StatusCode(201, new { id }); + } + + /// + /// EN: Update a campaign — validates merchant ownership. + /// VI: Cập nhật chiến dịch — kiểm tra quyền sở hữu merchant. + /// + [HttpPut("campaigns/{campaignId:guid}")] + public async Task UpdateCampaign(Guid campaignId, [FromBody] CreateCampaignRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + await using var conn = _dbFactory.CreateConnection("promotion_service"); + var rows = await conn.ExecuteAsync( + @"UPDATE campaigns SET name=@Name, description=@Description, face_value=@FaceValue, + total_vouchers=@TotalVouchers, start_date=@StartDate, end_date=@EndDate, + updated_at=NOW() + WHERE id=@Id AND merchant_id=@MerchantId", + new { Id = campaignId, MerchantId = merchantId, req.Name, req.Description, + req.FaceValue, req.TotalVouchers, req.StartDate, req.EndDate }); + return rows > 0 ? Ok(new { id = campaignId }) : NotFound(); + } + + /// + /// EN: Disable a campaign (soft-delete by status_id=0) — validates merchant ownership. + /// VI: Vô hiệu hóa chiến dịch (soft-delete bằng status_id=0) — kiểm tra quyền sở hữu merchant. + /// + [HttpDelete("campaigns/{campaignId:guid}")] + public async Task DeleteCampaign(Guid campaignId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + await using var conn = _dbFactory.CreateConnection("promotion_service"); + await conn.ExecuteAsync( + @"UPDATE campaigns SET status_id=0, updated_at=NOW() + WHERE id=@Id AND merchant_id=@MerchantId", + new { Id = campaignId, MerchantId = merchantId }); + return NoContent(); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs new file mode 100644 index 00000000..36c1187d --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs @@ -0,0 +1,213 @@ +using Microsoft.AspNetCore.Mvc; +using Dapper; +using WebClientTpos.Server.Infrastructure; +using WebClientTpos.Server.Models; + +namespace WebClientTpos.Server.Controllers; + +/// +/// EN: F&B controller — CRUD for tables, kitchen tickets, and recipes. +/// Tables and kitchen use fnb_engine; recipes use catalog_service. +/// VI: Controller F&B — CRUD cho bàn ăn, phiếu bếp và công thức. +/// Bàn và bếp dùng fnb_engine; công thức dùng catalog_service. +/// +[ApiController] +[Route("api/bff")] +public class FnbController : ControllerBase +{ + private readonly TenantContext _tenant; + private readonly BffDbConnectionFactory _dbFactory; + + public FnbController(TenantContext tenant, BffDbConnectionFactory dbFactory) + { + _tenant = tenant; + _dbFactory = dbFactory; + } + + [HttpGet("shops/{shopId}/tables")] + public async Task GetTables(Guid shopId) + { + await using var conn = _dbFactory.CreateConnection("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); + } + + [HttpPost("tables")] + public async Task CreateTable([FromBody] CreateTableRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Contains(req.ShopId)) return Forbid(); + var id = Guid.NewGuid(); + await using var conn = _dbFactory.CreateConnection("fnb_engine"); + await conn.ExecuteAsync( + @"INSERT INTO tables (id, shop_id, table_number, capacity, zone, status_id, created_at, updated_at) + VALUES (@Id, @ShopId, @TableNumber, @Capacity, @Zone, 1, NOW(), NOW())", + new { Id = id, req.ShopId, req.TableNumber, req.Capacity, Zone = req.Zone ?? "" }); + return StatusCode(201, new { id }); + } + + [HttpPut("tables/{tableId:guid}")] + public async Task UpdateTable(Guid tableId, [FromBody] CreateTableRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("fnb_engine"); + var rows = await conn.ExecuteAsync( + @"UPDATE tables SET table_number=@TableNumber, capacity=@Capacity, zone=@Zone, updated_at=NOW() + WHERE id=@Id AND shop_id=ANY(@ShopIds)", + new { Id = tableId, req.TableNumber, req.Capacity, Zone = req.Zone ?? "", ShopIds = myShopIds.ToArray() }); + return rows > 0 ? Ok(new { id = tableId }) : NotFound(); + } + + [HttpDelete("tables/{tableId:guid}")] + public async Task DeleteTable(Guid tableId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("fnb_engine"); + await conn.ExecuteAsync( + "DELETE FROM tables WHERE id=@Id AND shop_id=ANY(@ShopIds)", + new { Id = tableId, ShopIds = myShopIds.ToArray() }); + return NoContent(); + } + + [HttpGet("shops/{shopId}/kitchen-tickets")] + public async Task GetKitchenTickets(Guid shopId, [FromQuery] string status = "pending") + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Ok(Array.Empty()); + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Any()) return Ok(Array.Empty()); + if (!myShopIds.Contains(shopId)) return Ok(Array.Empty()); + var targetShopIds = new List { shopId }; + try + { + await using var conn = _dbFactory.CreateConnection("fnb_engine"); + var whereStatus = status == "all" ? "" : "AND kt.status=@Status"; + var tickets = await conn.QueryAsync( + $@"SELECT kt.* FROM kitchen_tickets kt + JOIN sessions s ON kt.session_id = s.id + WHERE s.shop_id = ANY(@ShopIds) {whereStatus} + ORDER BY kt.priority DESC, kt.created_at", + new { ShopIds = targetShopIds.ToArray(), Status = status }); + return Ok(tickets); + } + catch { return Ok(Array.Empty()); } + } + + [HttpPut("kitchen/tickets/{ticketId:guid}/status")] + public async Task UpdateTicketStatus(Guid ticketId, [FromBody] UpdateTicketStatusRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + try + { + await using var conn = _dbFactory.CreateConnection("fnb_engine"); + await conn.ExecuteAsync( + @"UPDATE kitchen_tickets SET status=@Status, + completed_at=CASE WHEN @Status='completed' THEN NOW() ELSE NULL END + WHERE id=@Id", + new { Id = ticketId, req.Status }); + return Ok(new { id = ticketId }); + } + catch (Exception ex) { return BadRequest(new { error = ex.Message }); } + } + + [HttpGet("shops/{shopId}/recipes")] + public async Task GetRecipes(Guid shopId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Ok(Array.Empty()); + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Any()) return Ok(Array.Empty()); + if (!myShopIds.Contains(shopId)) return Ok(Array.Empty()); + var targetShopIds = new List { shopId }; + try + { + await using var conn = _dbFactory.CreateConnection("catalog_service"); + var recipes = await conn.QueryAsync( + @"SELECT r.*, (SELECT json_agg(row_to_json(ri)) FROM recipe_ingredients ri WHERE ri.recipe_id = r.id) as ingredients + FROM recipes r WHERE r.shop_id = ANY(@ShopIds) AND r.is_active = true + ORDER BY r.name", + new { ShopIds = targetShopIds.ToArray() }); + return Ok(recipes); + } + catch { return Ok(Array.Empty()); } + } + + [HttpPost("recipes")] + public async Task CreateRecipe([FromBody] CreateRecipeRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Contains(req.ShopId)) return Forbid(); + var id = Guid.NewGuid(); + await using var conn = _dbFactory.CreateConnection("catalog_service"); + await conn.OpenAsync(); + await using var tx = await conn.BeginTransactionAsync(); + await conn.ExecuteAsync( + @"INSERT INTO recipes (id, product_id, shop_id, name, instructions, prep_time_minutes, is_active, created_at, updated_at) + VALUES (@Id, @ProductId, @ShopId, @Name, @Instructions, @PrepTimeMinutes, true, NOW(), NOW())", + new { Id = id, req.ProductId, req.ShopId, req.Name, req.Instructions, req.PrepTimeMinutes }, tx); + foreach (var ing in req.Ingredients ?? new()) + await conn.ExecuteAsync( + @"INSERT INTO recipe_ingredients (id, recipe_id, ingredient_name, quantity, unit, cost_per_unit, created_at) + VALUES (@Id, @RecipeId, @IngredientName, @Quantity, @Unit, @CostPerUnit, NOW())", + new { Id = Guid.NewGuid(), RecipeId = id, ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit }, tx); + await tx.CommitAsync(); + return StatusCode(201, new { id }); + } + + [HttpPut("recipes/{recipeId:guid}")] + public async Task UpdateRecipe(Guid recipeId, [FromBody] CreateRecipeRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("catalog_service"); + await conn.OpenAsync(); + await using var tx = await conn.BeginTransactionAsync(); + await conn.ExecuteAsync( + "UPDATE recipes SET name=@Name, instructions=@Instructions, prep_time_minutes=@PrepTimeMinutes, updated_at=NOW() WHERE id=@Id AND shop_id=ANY(@ShopIds)", + new { Id = recipeId, req.Name, req.Instructions, req.PrepTimeMinutes, ShopIds = myShopIds.ToArray() }, tx); + await conn.ExecuteAsync("DELETE FROM recipe_ingredients WHERE recipe_id=@Id", new { Id = recipeId }, tx); + foreach (var ing in req.Ingredients ?? new()) + await conn.ExecuteAsync( + @"INSERT INTO recipe_ingredients (id, recipe_id, ingredient_name, quantity, unit, cost_per_unit, created_at) + VALUES (@Id, @RecipeId, @IngredientName, @Quantity, @Unit, @CostPerUnit, NOW())", + new { Id = Guid.NewGuid(), RecipeId = recipeId, ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit }, tx); + await tx.CommitAsync(); + return Ok(new { id = recipeId }); + } + + [HttpDelete("recipes/{recipeId:guid}")] + public async Task DeleteRecipe(Guid recipeId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("catalog_service"); + await conn.ExecuteAsync( + "UPDATE recipes SET is_active=false, updated_at=NOW() WHERE id=@Id AND shop_id=ANY(@ShopIds)", + new { Id = recipeId, ShopIds = myShopIds.ToArray() }); + return NoContent(); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/InventoryController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/InventoryController.cs new file mode 100644 index 00000000..12bd25b5 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/InventoryController.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Mvc; +using Dapper; +using WebClientTpos.Server.Infrastructure; +using WebClientTpos.Server.Models; + +namespace WebClientTpos.Server.Controllers; + +/// +/// EN: Inventory controller — get, update inventory items and transactions. +/// Cross-DB enrichment: inventory_service + catalog_service for product names. +/// VI: Controller tồn kho — lấy, cập nhật mặt hàng và giao dịch tồn kho. +/// Kết hợp cross-DB: inventory_service + catalog_service để lấy tên sản phẩm. +/// +[ApiController] +[Route("api/bff")] +public class InventoryController : ControllerBase +{ + private readonly TenantContext _tenant; + private readonly BffDbConnectionFactory _dbFactory; + + public InventoryController(TenantContext tenant, BffDbConnectionFactory dbFactory) + { + _tenant = tenant; + _dbFactory = dbFactory; + } + + /// + /// 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 (kết hợp cross-DB). + /// + [HttpGet("inventory")] + public async Task GetInventory([FromQuery] Guid? shopId = null) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + var myShopIds = await _tenant.GetShopIdsAsync(); + 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 = _dbFactory.CreateConnection("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() }); + + await using var catConn = _dbFactory.CreateConnection("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); + } + + /// + /// EN: Update inventory quantity for a specific item. + /// VI: Cập nhật số lượng tồn kho cho mặt hàng. + /// + [HttpPut("inventory/{inventoryId:guid}")] + public async Task UpdateInventory(Guid inventoryId, [FromBody] UpdateInventoryRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("inventory_service"); + var rows = await conn.ExecuteAsync( + @"UPDATE inventory_items SET quantity = @Quantity, reorder_level = @ReorderLevel, updated_at = NOW() + WHERE id = @Id AND shop_id = ANY(@ShopIds)", + new { Id = inventoryId, req.Quantity, req.ReorderLevel, ShopIds = myShopIds.ToArray() }); + return rows > 0 ? Ok(new { id = inventoryId }) : NotFound(); + } + + /// + /// EN: Get inventory transactions scoped to current merchant's shops. + /// VI: Lấy giao dịch tồn kho lọc theo shops của merchant hiện tại. + /// + [HttpGet("inventory/transactions")] + public async Task GetInventoryTransactions([FromQuery] Guid? shopId = null) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + var myShopIds = await _tenant.GetShopIdsAsync(); + 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 = _dbFactory.CreateConnection("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); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/MembershipController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/MembershipController.cs new file mode 100644 index 00000000..392f78b4 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/MembershipController.cs @@ -0,0 +1,113 @@ +using Microsoft.AspNetCore.Mvc; +using Dapper; +using WebClientTpos.Server.Infrastructure; +using WebClientTpos.Server.Models; + +namespace WebClientTpos.Server.Controllers; + +/// +/// EN: Membership controller — CRUD for members and membership levels. +/// VI: Controller thành viên — CRUD cho thành viên và cấp bậc membership. +/// +[ApiController] +[Route("api/bff")] +public class MembershipController : ControllerBase +{ + private readonly TenantContext _tenant; + private readonly BffDbConnectionFactory _dbFactory; + + public MembershipController(TenantContext tenant, BffDbConnectionFactory dbFactory) + { + _tenant = tenant; + _dbFactory = dbFactory; + } + + /// + /// 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 = _dbFactory.CreateConnection("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()); } + } + + /// + /// EN: Create a member — inserts with sensible defaults. + /// VI: Tạo thành viên — thêm với giá trị mặc định hợp lý. + /// + [HttpPost("members")] + public async Task CreateMember([FromBody] CreateMemberRequest req) + { + var id = Guid.NewGuid(); + var now = DateTime.UtcNow; + await using var conn = _dbFactory.CreateConnection("membership_service"); + await conn.ExecuteAsync( + @"INSERT INTO members (id, country_code, current_exp, current_level, gender, + is_deleted, total_exp_earned, created_at, updated_at) + VALUES (@Id, @CountryCode, 0, 1, @Gender, false, 0, @Now, @Now)", + new { Id = id, CountryCode = req.CountryCode ?? "VN", req.Gender, Now = now }); + return StatusCode(201, new { id }); + } + + /// + /// EN: Update a member's gender and preferences. + /// VI: Cập nhật giới tính và tùy chọn cá nhân của thành viên. + /// + [HttpPut("members/{memberId:guid}")] + public async Task UpdateMember(Guid memberId, [FromBody] UpdateMemberRequest req) + { + await using var conn = _dbFactory.CreateConnection("membership_service"); + var rows = await conn.ExecuteAsync( + @"UPDATE members SET gender=@Gender, preferences=@Preferences::jsonb, updated_at=NOW() + WHERE id=@Id AND is_deleted=false", + new { Id = memberId, req.Gender, Preferences = req.Preferences ?? "{}" }); + return rows > 0 ? Ok(new { id = memberId }) : NotFound(); + } + + /// + /// EN: Soft-delete a member. + /// VI: Xóa mềm thành viên. + /// + [HttpDelete("members/{memberId:guid}")] + public async Task DeleteMember(Guid memberId) + { + await using var conn = _dbFactory.CreateConnection("membership_service"); + await conn.ExecuteAsync( + @"UPDATE members SET is_deleted=true, updated_at=NOW() WHERE id=@Id", + new { Id = memberId }); + return NoContent(); + } + + /// + /// EN: Get membership level definitions with member counts. + /// VI: Lấy định nghĩa cấp bậc membership với số lượng thành viên. + /// + [HttpGet("membership/levels")] + public async Task GetMembershipLevels() + { + try + { + await using var conn = _dbFactory.CreateConnection("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()); } + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs new file mode 100644 index 00000000..1898628b --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs @@ -0,0 +1,351 @@ +using Microsoft.AspNetCore.Mvc; +using Dapper; +using System.IdentityModel.Tokens.Jwt; +using WebClientTpos.Server.Infrastructure; +using WebClientTpos.Server.Models; + +namespace WebClientTpos.Server.Controllers; + +/// +/// EN: Order controller — list, detail, cancel orders; POS checkout and dashboard. +/// All queries are scoped to the current merchant's shops. +/// VI: Controller đơn hàng — danh sách, chi tiết, hủy đơn; POS thanh toán và dashboard. +/// Tất cả queries đều lọc theo shops của merchant hiện tại. +/// +[ApiController] +[Route("api/bff")] +public class OrderController : ControllerBase +{ + private readonly TenantContext _tenant; + private readonly BffDbConnectionFactory _dbFactory; + + public OrderController(TenantContext tenant, BffDbConnectionFactory dbFactory) + { + _tenant = tenant; + _dbFactory = dbFactory; + } + + /// + /// EN: Get orders filtered by shop and date range (today/week/month). + /// VI: Lấy đơn hàng theo shop và khoảng ngày (hôm nay/tuần/tháng). + /// + [HttpGet("orders")] + public async Task GetOrders( + [FromQuery] Guid? shopId = null, + [FromQuery] string? filter = "today") + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + var myShopIds = await _tenant.GetShopIdsAsync(); + 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; + + var dateCondition = filter switch + { + "week" => "AND o.created_at >= (CURRENT_DATE - INTERVAL '7 days')", + "month" => "AND o.created_at >= (CURRENT_DATE - INTERVAL '30 days')", + _ => "AND DATE(o.created_at) = CURRENT_DATE" + }; + + await using var conn = _dbFactory.CreateConnection("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, COALESCE(o.payment_method, 'cash') as payment_method, + o.notes + FROM orders o + JOIN order_statuses os ON o.status_id = os.id + WHERE o.shop_id = ANY(@ShopIds) {dateCondition} + ORDER BY o.created_at DESC LIMIT 200", + new { ShopIds = targetShopIds.ToArray() }); + return Ok(orders); + } + + /// + /// EN: Get full order detail with items — validates shop ownership. + /// VI: Lấy chi tiết đơn hàng kèm items — kiểm tra quyền sở hữu shop. + /// + [HttpGet("orders/{orderId:guid}")] + public async Task GetOrderDetail(Guid orderId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("order_service"); + var order = await conn.QueryFirstOrDefaultAsync( + @"SELECT o.id, o.shop_id, o.total_amount, os.name as status, o.status_id, + COALESCE(o.payment_method, 'cash') as payment_method, o.notes, o.created_at + FROM orders o + JOIN order_statuses os ON o.status_id = os.id + WHERE o.id = @Id AND o.shop_id = ANY(@ShopIds)", + new { Id = orderId, ShopIds = myShopIds.ToArray() }); + + if (order == null) return NotFound(); + + var items = await conn.QueryAsync( + @"SELECT id, product_name, quantity, unit_price, (quantity * unit_price) as subtotal + FROM order_items + WHERE order_id = @OrderId", + new { OrderId = orderId }); + + return Ok(new { order, items }); + } + + /// + /// EN: Cancel an order — validates ownership; rejects completed/already-cancelled. + /// VI: Hủy đơn hàng — kiểm tra quyền sở hữu; từ chối nếu đã xong hoặc đã hủy. + /// + [HttpPut("orders/{orderId:guid}/cancel")] + public async Task CancelOrder(Guid orderId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("order_service"); + var rows = await conn.ExecuteAsync( + @"UPDATE orders SET status_id=6, updated_at=NOW() + WHERE id=@Id AND shop_id = ANY(@ShopIds) AND status_id NOT IN (5,6)", + new { Id = orderId, ShopIds = myShopIds.ToArray() }); + return rows > 0 ? Ok(new { id = orderId }) : BadRequest(new { message = "Đơn hàng đã hoàn thành hoặc đã hủy." }); + } + + /// + /// EN: Create a POS order — inserts order + order_items, marks as Paid+Completed. + /// VI: Tạo đơn POS — insert order + order_items, đánh dấu Đã thanh toán + Hoàn thành. + /// + [HttpPost("pos/orders")] + public async Task CreatePosOrder([FromBody] CreatePosOrderRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) + { + var userId = _tenant.GetUserId(); + Console.Error.WriteLine($"[BFF] CreatePosOrder: merchantId null. userId={userId}, hasAuth={Request.Headers.ContainsKey("Authorization")}"); + return Unauthorized(new { message = "Merchant not found", userId }); + } + + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Contains(req.ShopId)) + { + Console.Error.WriteLine($"[BFF] CreatePosOrder: shop {req.ShopId} not owned by merchant {merchantId}. Owned: [{string.Join(", ", myShopIds)}]"); + return Unauthorized(new { message = "Shop not owned by current merchant" }); + } + + var orderId = Guid.NewGuid(); + var now = DateTime.UtcNow; + var totalAmount = req.Items.Sum(i => i.Quantity * i.UnitPrice); + var transactionId = $"POS-{now:yyyyMMdd}-{orderId.ToString()[..8].ToUpper()}"; + + try + { + await using var conn = _dbFactory.CreateConnection("order_service"); + await conn.OpenAsync(); + await using var tx = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync( + @"INSERT INTO orders (id, shop_id, status_id, total_amount, customer_id, notes, payment_method, created_at, updated_at) + VALUES (@Id, @ShopId, 5, @Total, @CustomerId, @Notes, @PaymentMethod, @Now, @Now)", + new + { + Id = orderId, + req.ShopId, + Total = totalAmount, + CustomerId = (Guid?)null, + Notes = $"POS Order | {transactionId}", + PaymentMethod = req.PaymentMethod ?? "cash", + Now = now + }, tx); + + foreach (var item in req.Items) + { + await conn.ExecuteAsync( + @"INSERT INTO order_items (id, order_id, product_id, product_name, product_type, quantity, unit_price, status) + VALUES (@Id, @OrderId, @ProductId, @ProductName, 'PreparedFood', @Quantity, @UnitPrice, 'Completed')", + new + { + Id = Guid.NewGuid(), + OrderId = orderId, + item.ProductId, + item.ProductName, + item.Quantity, + item.UnitPrice + }, tx); + } + + await tx.CommitAsync(); + + return Ok(new + { + orderId, + transactionId, + totalAmount, + status = "Completed", + createdAt = now + }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[BFF] CreatePosOrder error: {ex.Message}"); + return StatusCode(500, new { message = "Failed to create order", error = ex.Message }); + } + } + + /// + /// EN: Get POS dashboard data — daily revenue, order count, popular items, payment breakdown, hourly chart. + /// VI: Lấy dữ liệu dashboard POS — doanh thu ngày, số đơn, món bán chạy, thanh toán, biểu đồ theo giờ. + /// + [HttpGet("pos/dashboard")] + public async Task GetPosDashboard([FromQuery] Guid? shopId = null) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Ok(EmptyDashboard()); + + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Any()) + return Ok(EmptyDashboard()); + + if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) + return Ok(EmptyDashboard()); + + var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; + + decimal revenue = 0; int orderCount = 0; int itemsSold = 0; + List popularItems = new(); + List paymentBreakdown = new(); + List hourlyRevenue = new(); + List recentOrders = new(); + + try + { + await using var conn = _dbFactory.CreateConnection("order_service"); + + var summary = await conn.QueryFirstOrDefaultAsync( + @"SELECT COUNT(*) as cnt, COALESCE(SUM(total_amount), 0) as total + FROM orders WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE", + new { ShopIds = targetShopIds.ToArray() }); + if (summary != null) + { + orderCount = (int)(long)summary.cnt; + revenue = (decimal)summary.total; + } + + var orders = await conn.QueryAsync( + @"SELECT o.id, o.total_amount, o.created_at, os.name as status, + COALESCE(o.payment_method, 'cash') as payment_method + FROM orders o + JOIN order_statuses os ON o.status_id = os.id + WHERE o.shop_id = ANY(@ShopIds) AND DATE(o.created_at) = CURRENT_DATE + ORDER BY o.created_at DESC LIMIT 50", + new { ShopIds = targetShopIds.ToArray() }); + recentOrders = orders.Select(o => (object)new + { + id = ((Guid)o.id).ToString()[..8].ToUpper(), + total = (decimal)o.total_amount, + time = ((DateTime)o.created_at).ToString("HH:mm"), + status = (string)o.status, + method = MapPaymentMethod((string)o.payment_method) + }).ToList(); + + try + { + var payments = await conn.QueryAsync( + @"SELECT COALESCE(payment_method, 'cash') as method, + SUM(total_amount) as total, COUNT(*) as cnt + FROM orders + WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE + GROUP BY COALESCE(payment_method, 'cash') + ORDER BY total DESC", + new { ShopIds = targetShopIds.ToArray() }); + var totalRev = payments.Sum(p => (decimal)p.total); + paymentBreakdown = payments.Select(p => (object)new + { + method = MapPaymentMethod((string)p.method), + amount = (decimal)p.total, + pct = totalRev > 0 ? (int)Math.Round((decimal)p.total / totalRev * 100) : 0 + }).ToList(); + } + catch { /* payment_method column may not exist */ } + + try + { + var hourly = await conn.QueryAsync( + @"SELECT EXTRACT(HOUR FROM created_at)::int as hr, + SUM(total_amount) as total + FROM orders + WHERE shop_id = ANY(@ShopIds) AND DATE(created_at) = CURRENT_DATE + GROUP BY 1 ORDER BY 1", + new { ShopIds = targetShopIds.ToArray() }); + var maxHr = hourly.Any() ? hourly.Max(h => (decimal)h.total) : 1; + for (int h = 6; h <= 22; h++) + { + var match = hourly.FirstOrDefault(x => (int)x.hr == h); + var val = match != null ? (decimal)match.total : 0; + hourlyRevenue.Add(new { hour = $"{h}h", revenue = val, pct = maxHr > 0 ? (int)(val / maxHr * 100) : 0 }); + } + } + catch { /* OK */ } + + try + { + var popular = await conn.QueryAsync( + @"SELECT oi.product_name as name, SUM(oi.quantity) as qty, + SUM(oi.quantity * oi.unit_price) as revenue + FROM order_items oi + JOIN orders o ON oi.order_id = o.id + WHERE o.shop_id = ANY(@ShopIds) AND DATE(o.created_at) = CURRENT_DATE + GROUP BY oi.product_name + ORDER BY qty DESC LIMIT 10", + new { ShopIds = targetShopIds.ToArray() }); + itemsSold = (int)popular.Sum(p => (long)p.qty); + popularItems = popular.Select(p => (object)new + { + name = (string)p.name, + qty = (int)(long)p.qty, + revenue = (decimal)p.revenue + }).ToList(); + } + catch { /* order_items table may not exist yet */ } + } + catch { /* order_service DB not available */ } + + return Ok(new + { + revenue, + orderCount, + itemsSold, + avgOrderValue = orderCount > 0 ? revenue / orderCount : 0, + popularItems, + paymentBreakdown, + hourlyRevenue, + recentOrders + }); + } + + private static object EmptyDashboard() => new + { + revenue = 0m, orderCount = 0, itemsSold = 0, avgOrderValue = 0m, + popularItems = Array.Empty(), + paymentBreakdown = Array.Empty(), + hourlyRevenue = Array.Empty(), + recentOrders = Array.Empty() + }; + + private static string MapPaymentMethod(string method) => method switch + { + "cash" => "Tiền mặt", + "card" => "Thẻ", + "qr" => "QR Code", + "transfer" => "Chuyển khoản", + "ewallet" => "Ví điện tử", + _ => method + }; +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs new file mode 100644 index 00000000..f0b17474 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs @@ -0,0 +1,105 @@ +using Microsoft.AspNetCore.Mvc; +using Dapper; +using WebClientTpos.Server.Infrastructure; + +namespace WebClientTpos.Server.Controllers; + +/// +/// EN: Reports controller — revenue reports and top products, scoped to merchant's shops. +/// VI: Controller báo cáo — báo cáo doanh thu và sản phẩm bán chạy, lọc theo shops của merchant. +/// +[ApiController] +[Route("api/bff")] +public class ReportsController : ControllerBase +{ + private readonly TenantContext _tenant; + private readonly BffDbConnectionFactory _dbFactory; + + public ReportsController(TenantContext tenant, BffDbConnectionFactory dbFactory) + { + _tenant = tenant; + _dbFactory = dbFactory; + } + + /// + /// EN: Get revenue report grouped by day/week/month — scoped to merchant's shops. + /// VI: Lấy báo cáo doanh thu theo ngày/tuần/tháng — lọc theo shops của merchant. + /// + [HttpGet("reports/revenue")] + public async Task GetRevenueReport( + [FromQuery] string period = "daily", + [FromQuery] Guid? shopId = null) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Ok(Array.Empty()); + + var myShopIds = await _tenant.GetShopIdsAsync(); + 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 = _dbFactory.CreateConnection("order_service"); + var sql = period switch + { + "weekly" => @"SELECT date_trunc('week', created_at)::date as period, COUNT(*) as order_count, COALESCE(SUM(total_amount),0) as revenue + FROM orders WHERE shop_id = ANY(@ShopIds) AND status_id IN (3,5) AND created_at >= CURRENT_DATE - INTERVAL '84 days' + GROUP BY 1 ORDER BY 1 DESC", + "monthly" => @"SELECT date_trunc('month', created_at)::date as period, COUNT(*) as order_count, COALESCE(SUM(total_amount),0) as revenue + FROM orders WHERE shop_id = ANY(@ShopIds) AND status_id IN (3,5) AND created_at >= CURRENT_DATE - INTERVAL '365 days' + GROUP BY 1 ORDER BY 1 DESC", + _ => @"SELECT DATE(created_at) as period, COUNT(*) as order_count, COALESCE(SUM(total_amount),0) as revenue + FROM orders WHERE shop_id = ANY(@ShopIds) AND status_id IN (3,5) AND created_at >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY DATE(created_at) ORDER BY period DESC" + }; + + try + { + var report = await conn.QueryAsync(sql, new { ShopIds = targetShopIds.ToArray() }); + return Ok(report); + } + catch { return Ok(Array.Empty()); } + } + + /// + /// EN: Get top-selling products — scoped to merchant's shops. + /// VI: Lấy sản phẩm bán chạy nhất — lọc theo shops của merchant. + /// + [HttpGet("reports/top-products")] + public async Task GetTopProducts( + [FromQuery] Guid? shopId = null, + [FromQuery] int limit = 10) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Ok(Array.Empty()); + + var myShopIds = await _tenant.GetShopIdsAsync(); + 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 = _dbFactory.CreateConnection("order_service"); + try + { + var rows = await conn.QueryAsync( + @"SELECT oi.product_name, + SUM(oi.quantity)::bigint AS total_sold, + SUM(oi.quantity * oi.unit_price)::numeric AS total_revenue + FROM order_items oi + JOIN orders o ON oi.order_id = o.id + WHERE o.shop_id = ANY(@ShopIds) + AND o.status_id IN (3, 5) + GROUP BY oi.product_name + ORDER BY total_sold DESC + LIMIT @Limit", + new { ShopIds = targetShopIds.ToArray(), Limit = limit }); + return Ok(rows); + } + catch { return Ok(Array.Empty()); } + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs new file mode 100644 index 00000000..23222d19 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs @@ -0,0 +1,259 @@ +using Microsoft.AspNetCore.Mvc; +using Dapper; +using WebClientTpos.Server.Infrastructure; +using WebClientTpos.Server.Models; + +namespace WebClientTpos.Server.Controllers; + +/// +/// EN: Shop management controller — CRUD for shops, settings, stats, and devices. +/// All endpoints are scoped to the current merchant's shops. +/// VI: Controller quản lý cửa hàng — CRUD cho shops, settings, stats và devices. +/// Tất cả endpoints đều lọc theo shops của merchant hiện tại. +/// +[ApiController] +[Route("api/bff")] +public class ShopController : ControllerBase +{ + private readonly TenantContext _tenant; + private readonly BffDbConnectionFactory _dbFactory; + + public ShopController(TenantContext tenant, BffDbConnectionFactory dbFactory) + { + _tenant = tenant; + _dbFactory = dbFactory; + } + + /// + /// EN: Get all shops belonging to the current merchant. + /// VI: Lấy tất cả cửa hàng thuộc merchant hiện tại. + /// + [HttpGet("shops")] + public async Task GetShops() + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + await using var conn = _dbFactory.CreateConnection("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); + } + + /// + /// EN: Get shop by ID — validates merchant ownership. + /// VI: Lấy cửa hàng theo ID — kiểm tra quyền sở hữu merchant. + /// + [HttpGet("shops/{shopId:guid}")] + public async Task GetShopById(Guid shopId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return NotFound(new { message = "Shop not found" }); + + await using var conn = _dbFactory.CreateConnection("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); + } + + /// + /// EN: Update shop info — validates ownership. + /// VI: Cập nhật thông tin cửa hàng — kiểm tra quyền sở hữu. + /// + [HttpPut("shops/{shopId:guid}")] + public async Task UpdateShop(Guid shopId, [FromBody] UpdateShopRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Contains(shopId)) + return Forbid(); + + await using var conn = _dbFactory.CreateConnection("merchant_service"); + var rows = await conn.ExecuteAsync( + @"UPDATE shops SET name=@Name, phone=@Phone, email=@Email, description=@Description, + open_time=@OpenTime, close_time=@CloseTime, updated_at=NOW() + WHERE id=@ShopId AND id = ANY(@ShopIds)", + new { req.Name, req.Phone, req.Email, req.Description, + req.OpenTime, req.CloseTime, ShopId = shopId, ShopIds = myShopIds.ToArray() }); + return rows > 0 ? Ok(new { id = shopId }) : NotFound(); + } + + /// + /// EN: Get shop settings (features_config, open/close time, open days). + /// VI: Lấy cài đặt cửa hàng (features_config, giờ mở/đóng cửa, ngày mở cửa). + /// + [HttpGet("shops/{shopId:guid}/settings")] + public async Task GetShopSettings(Guid shopId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return NotFound(); + + await using var conn = _dbFactory.CreateConnection("merchant_service"); + var settings = await conn.QueryFirstOrDefaultAsync( + @"SELECT features_config::text as features_config, + open_time::text as open_time, + close_time::text as close_time, + open_days + FROM shops + WHERE id = @ShopId AND merchant_id = @MerchantId AND is_deleted = false", + new { ShopId = shopId, MerchantId = merchantId }); + + if (settings == null) return NotFound(); + return Ok(settings); + } + + /// + /// EN: Update shop settings — validates ownership. + /// VI: Cập nhật cài đặt cửa hàng — kiểm tra quyền sở hữu. + /// + [HttpPut("shops/{shopId:guid}/settings")] + public async Task UpdateShopSettings(Guid shopId, [FromBody] UpdateShopSettingsRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Contains(shopId)) return Forbid(); + + await using var conn = _dbFactory.CreateConnection("merchant_service"); + try + { + await conn.ExecuteAsync( + @"UPDATE shops + SET features_config = @FeaturesConfig::jsonb, + open_time = @OpenTime::time, + close_time = @CloseTime::time, + open_days = @OpenDays, + updated_at = NOW() + WHERE id = @ShopId AND id = ANY(@ShopIds)", + new + { + ShopId = shopId, + ShopIds = myShopIds.ToArray(), + FeaturesConfig = string.IsNullOrWhiteSpace(req.FeaturesConfig) ? "{}" : req.FeaturesConfig, + OpenTime = string.IsNullOrWhiteSpace(req.OpenTime) ? (object)DBNull.Value : req.OpenTime, + CloseTime= string.IsNullOrWhiteSpace(req.CloseTime) ? (object)DBNull.Value : req.CloseTime, + OpenDays = req.OpenDays + }); + return Ok(new { success = true }); + } + catch (Exception ex) { return BadRequest(new { error = ex.Message }); } + } + + /// + /// 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 _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Any()) + return Ok(Array.Empty()); + + var shopIdsArray = myShopIds.ToArray(); + + // EN: Products per shop (scoped) / VI: Số sản phẩm mỗi shop + Dictionary productCounts = new(); + try + { + await using var catConn = _dbFactory.CreateConnection("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 */ } + + // EN: Orders per shop + revenue (scoped) / VI: Số đơn + doanh thu mỗi shop + Dictionary orderCounts = new(); + Dictionary revenues = new(); + try + { + await using var orderConn = _dbFactory.CreateConnection("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 */ } + + // EN: Staff per shop (scoped) / VI: Số nhân viên mỗi shop + Dictionary staffCounts = new(); + try + { + await using var mConn = _dbFactory.CreateConnection("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: Get device tokens registered for this merchant's staff. + /// VI: Lấy danh sách device token đã đăng ký cho nhân viên của merchant. + /// + [HttpGet("devices")] + public async Task GetDevices() + { + await using var conn = _dbFactory.CreateConnection("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); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs new file mode 100644 index 00000000..50305894 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs @@ -0,0 +1,227 @@ +using Microsoft.AspNetCore.Mvc; +using Dapper; +using WebClientTpos.Server.Infrastructure; +using WebClientTpos.Server.Models; + +namespace WebClientTpos.Server.Controllers; + +/// +/// EN: Staff controller — CRUD for staff, roles, and schedules, scoped to current merchant. +/// VI: Controller nhân viên — CRUD cho nhân viên, vai trò và lịch làm việc, lọc theo merchant hiện tại. +/// +[ApiController] +[Route("api/bff")] +public class StaffController : ControllerBase +{ + private readonly TenantContext _tenant; + private readonly BffDbConnectionFactory _dbFactory; + + public StaffController(TenantContext tenant, BffDbConnectionFactory dbFactory) + { + _tenant = tenant; + _dbFactory = dbFactory; + } + + /// + /// EN: Get all staff members for the current merchant. + /// VI: Lấy tất cả nhân viên của merchant hiện tại. + /// + [HttpGet("staff")] + public async Task GetStaff() + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + await using var conn = _dbFactory.CreateConnection("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); + } + + /// + /// 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 _tenant.GetMerchantIdAsync(); + if (merchantId == null || merchantId.Value != req.MerchantId) + return Unauthorized(); + + var id = Guid.NewGuid(); + await using var conn = _dbFactory.CreateConnection("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, permissions, user_id, joined_at, created_at) + VALUES (@Id, @MerchantId, @EmployeeCode, @Phone, @Email, @RoleId, @StatusId, 0, @UserId, NOW(), NOW())", + new { Id = id, req.MerchantId, req.EmployeeCode, req.Phone, req.Email, RoleId = roleId, StatusId = statusId, UserId = Guid.Empty }); + return CreatedAtAction(nameof(GetStaff), new { }, new { id }); + } + + /// + /// EN: Update a staff member — validates merchant ownership. + /// VI: Cập nhật nhân viên — kiểm tra quyền sở hữu merchant. + /// + [HttpPut("staff/{staffId:guid}")] + public async Task UpdateStaff(Guid staffId, [FromBody] CreateStaffRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null || merchantId.Value != req.MerchantId) + return Unauthorized(); + + await using var conn = _dbFactory.CreateConnection("merchant_service"); + var roleId = await conn.QueryFirstOrDefaultAsync( + "SELECT id FROM staff_roles WHERE name = @Role", new { req.Role }); + if (roleId == 0) roleId = 1; + + var rows = await conn.ExecuteAsync( + @"UPDATE merchant_staff SET employee_code = @EmployeeCode, phone = @Phone, + email = @Email, role_id = @RoleId + WHERE id = @Id AND merchant_id = @MerchantId", + new { Id = staffId, req.MerchantId, req.EmployeeCode, req.Phone, req.Email, RoleId = roleId }); + return rows > 0 ? Ok(new { id = staffId }) : NotFound(); + } + + /// + /// EN: Terminate (soft-delete) a staff member. + /// VI: Chấm dứt (xóa mềm) nhân viên. + /// + [HttpDelete("staff/{staffId:guid}")] + public async Task DeleteStaff(Guid staffId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + await using var conn = _dbFactory.CreateConnection("merchant_service"); + var terminatedStatusId = await conn.QueryFirstOrDefaultAsync( + "SELECT id FROM staff_statuses WHERE name = 'Terminated'"); + if (terminatedStatusId == 0) terminatedStatusId = 3; + + await conn.ExecuteAsync( + @"UPDATE merchant_staff SET status_id = @StatusId, terminated_at = NOW() + WHERE id = @Id AND merchant_id = @MerchantId", + new { Id = staffId, StatusId = terminatedStatusId, MerchantId = merchantId.Value }); + return NoContent(); + } + + /// + /// EN: Get all available staff roles. + /// VI: Lấy tất cả vai trò nhân viên hiện có. + /// + [HttpGet("staff/roles")] + public async Task GetStaffRoles() + { + try + { + await using var conn = _dbFactory.CreateConnection("merchant_service"); + var roles = await conn.QueryAsync("SELECT id, name FROM staff_roles ORDER BY id"); + return Ok(roles); + } + catch { return Ok(Array.Empty()); } + } + + /// + /// EN: Get staff schedules — enriched with staff names/roles. + /// VI: Lấy lịch làm việc nhân viên — bổ sung tên và vai trò nhân viên. + /// + [HttpGet("staff/schedules")] + public async Task GetStaffSchedules([FromQuery] Guid? shopId = null) + { + try + { + await using var conn = _dbFactory.CreateConnection("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 }); + + await using var mConn = _dbFactory.CreateConnection("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()); } + } + + /// + /// EN: Create a staff schedule — validates shop ownership. + /// VI: Tạo lịch làm việc nhân viên — kiểm tra quyền sở hữu shop. + /// + [HttpPost("schedules")] + public async Task CreateSchedule([FromBody] CreateScheduleRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + var myShopIds = await _tenant.GetShopIdsAsync(); + if (!myShopIds.Contains(req.ShopId)) return Forbid(); + var id = Guid.NewGuid(); + await using var conn = _dbFactory.CreateConnection("booking_service"); + await conn.ExecuteAsync( + @"INSERT INTO staff_schedules (id, shop_id, staff_id, day_of_week, start_time, end_time) + VALUES (@Id, @ShopId, @StaffId, @DayOfWeek, @StartTime::time, @EndTime::time)", + new { Id = id, req.ShopId, req.StaffId, req.DayOfWeek, req.StartTime, req.EndTime }); + return StatusCode(201, new { id }); + } + + /// + /// EN: Update a staff schedule — validates shop ownership. + /// VI: Cập nhật lịch làm việc nhân viên — kiểm tra quyền sở hữu shop. + /// + [HttpPut("schedules/{scheduleId:guid}")] + public async Task UpdateSchedule(Guid scheduleId, [FromBody] CreateScheduleRequest req) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("booking_service"); + var rows = await conn.ExecuteAsync( + "UPDATE staff_schedules SET day_of_week=@DayOfWeek, start_time=@StartTime::time, end_time=@EndTime::time WHERE id=@Id AND shop_id=ANY(@ShopIds)", + new { Id = scheduleId, req.DayOfWeek, req.StartTime, req.EndTime, ShopIds = myShopIds.ToArray() }); + return rows > 0 ? Ok(new { id = scheduleId }) : NotFound(); + } + + /// + /// EN: Delete a staff schedule — validates shop ownership. + /// VI: Xóa lịch làm việc nhân viên — kiểm tra quyền sở hữu shop. + /// + [HttpDelete("schedules/{scheduleId:guid}")] + public async Task DeleteSchedule(Guid scheduleId) + { + var merchantId = await _tenant.GetMerchantIdAsync(); + if (merchantId == null) return Forbid(); + var myShopIds = await _tenant.GetShopIdsAsync(); + await using var conn = _dbFactory.CreateConnection("booking_service"); + await conn.ExecuteAsync( + "DELETE FROM staff_schedules WHERE id=@Id AND shop_id=ANY(@ShopIds)", + new { Id = scheduleId, ShopIds = myShopIds.ToArray() }); + return NoContent(); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/BffDbConnectionFactory.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/BffDbConnectionFactory.cs new file mode 100644 index 00000000..db180cdd --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/BffDbConnectionFactory.cs @@ -0,0 +1,34 @@ +using Npgsql; + +namespace WebClientTpos.Server.Infrastructure; + +/// +/// EN: Centralized database connection factory for BFF. +/// Replaces scattered ConnStr() calls with a DI-injectable service. +/// DB host/port/credentials are configurable via environment variables. +/// VI: Factory kết nối database tập trung cho BFF. +/// Thay thế các lời gọi ConnStr() rải rác bằng service inject qua DI. +/// Host/port/credentials DB cấu hình qua biến môi trường. +/// +public class BffDbConnectionFactory +{ + private readonly string _host; + private readonly string _port; + private readonly string _user; + private readonly string _pass; + + public BffDbConnectionFactory() + { + _host = Environment.GetEnvironmentVariable("BFF_DB_HOST") ?? "localhost"; + _port = Environment.GetEnvironmentVariable("BFF_DB_PORT") ?? "5432"; + _user = Environment.GetEnvironmentVariable("BFF_DB_USER") ?? "goodgo"; + _pass = Environment.GetEnvironmentVariable("BFF_DB_PASS") ?? "goodgo_dev_2024"; + } + + /// + /// EN: Create a new NpgsqlConnection for the specified database. + /// VI: Tạo NpgsqlConnection mới cho database chỉ định. + /// + public NpgsqlConnection CreateConnection(string database) => + new($"Host={_host};Port={_port};Database={database};Username={_user};Password={_pass}"); +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/TenantContext.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/TenantContext.cs new file mode 100644 index 00000000..a5d5bf47 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/TenantContext.cs @@ -0,0 +1,135 @@ +using System.IdentityModel.Tokens.Jwt; +using Dapper; + +namespace WebClientTpos.Server.Infrastructure; + +/// +/// EN: Scoped service that resolves the current tenant (merchant) from JWT token. +/// Caches merchantId and shopIds per-request to avoid repeated DB lookups. +/// Each endpoint that previously called GetCurrentMerchantIdAsync() + GetMyShopIdsAsync() +/// now simply injects this service. +/// VI: Service scoped giải quyết tenant (merchant) hiện tại từ JWT token. +/// Cache merchantId và shopIds mỗi request để tránh truy vấn DB lặp lại. +/// Mỗi endpoint trước đây gọi GetCurrentMerchantIdAsync() + GetMyShopIdsAsync() +/// giờ chỉ cần inject service này. +/// +public class TenantContext +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly BffDbConnectionFactory _dbFactory; + + // EN: Per-request cache / VI: Cache theo request + private Guid? _cachedUserId; + private bool _userIdResolved; + private Guid? _cachedMerchantId; + private bool _merchantIdResolved; + private List? _cachedShopIds; + + public TenantContext(IHttpContextAccessor httpContextAccessor, BffDbConnectionFactory dbFactory) + { + _httpContextAccessor = httpContextAccessor; + _dbFactory = dbFactory; + } + + /// + /// EN: Extract user ID from JWT token in Authorization header. + /// VI: Trích xuất user ID từ JWT token trong header Authorization. + /// + public Guid? GetUserId() + { + if (_userIdResolved) return _cachedUserId; + + _userIdResolved = true; + var authHeader = _httpContextAccessor.HttpContext?.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)) + { + _cachedUserId = userId; + return userId; + } + } + catch { /* Invalid token */ } + + return null; + } + + /// + /// EN: Get current user's merchant ID (cached per-request). + /// VI: Lấy merchant ID của user hiện tại (cache theo request). + /// + public async Task GetMerchantIdAsync() + { + if (_merchantIdResolved) return _cachedMerchantId; + + _merchantIdResolved = true; + var userId = GetUserId(); + if (userId == null) return null; + + await using var conn = _dbFactory.CreateConnection("merchant_service"); + _cachedMerchantId = await conn.QueryFirstOrDefaultAsync( + "SELECT id FROM merchants WHERE user_id = @UserId AND is_deleted = false", + new { UserId = userId }); + return _cachedMerchantId; + } + + /// + /// EN: Get list of shop IDs owned by the current merchant (cached per-request). + /// VI: Lấy danh sách shop IDs thuộc merchant hiện tại (cache theo request). + /// + public async Task> GetShopIdsAsync() + { + if (_cachedShopIds != null) return _cachedShopIds; + + var merchantId = await GetMerchantIdAsync(); + if (merchantId == null) + { + _cachedShopIds = new List(); + return _cachedShopIds; + } + + await using var conn = _dbFactory.CreateConnection("merchant_service"); + var ids = await conn.QueryAsync( + "SELECT id FROM shops WHERE merchant_id = @MerchantId AND is_deleted = false", + new { MerchantId = merchantId }); + _cachedShopIds = ids.ToList(); + return _cachedShopIds; + } + + /// + /// EN: Validate that the given shopId belongs to the current merchant. + /// VI: Xác nhận shopId thuộc merchant hiện tại. + /// + public async Task OwnsShopAsync(Guid shopId) + { + var shopIds = await GetShopIdsAsync(); + return shopIds.Contains(shopId); + } + + /// + /// EN: Get target shop IDs — if shopId is specified, verify ownership and return it; + /// otherwise return all merchant's shops. + /// VI: Lấy danh sách shop đích — nếu shopId chỉ định, kiểm tra quyền sở hữu; + /// nếu không thì trả về tất cả shops của merchant. + /// + public async Task?> GetTargetShopIdsAsync(Guid? shopId = null) + { + var merchantId = await GetMerchantIdAsync(); + if (merchantId == null) return null; + + var myShopIds = await GetShopIdsAsync(); + if (!myShopIds.Any()) return null; + + if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) + return null; + + return shopId.HasValue ? new List { shopId.Value } : myShopIds; + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Models/BffDtos.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Models/BffDtos.cs new file mode 100644 index 00000000..633e62b7 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Models/BffDtos.cs @@ -0,0 +1,51 @@ +namespace WebClientTpos.Server.Models; + +// ═══════════════════════════════════════════════════════════════════════════════ +// EN: Request/Response DTOs for BFF endpoints — extracted from BffDataController. +// VI: DTOs request/response cho BFF endpoints — trích xuất từ BffDataController. +// ═══════════════════════════════════════════════════════════════════════════════ + +// ═══ Catalog ═══ +public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, string? Type, string? Sku, string? ImageUrl); +public record CreateCategoryRequest(Guid ShopId, string Name, string? Description, int DisplayOrder); + +// ═══ Staff ═══ +public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role); +public record CreateScheduleRequest(Guid ShopId, Guid StaffId, int DayOfWeek, string StartTime, string EndTime); + +// ═══ Orders & POS ═══ +public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List Items); +public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice); + +// ═══ Inventory ═══ +public record UpdateInventoryRequest(int Quantity, int ReorderLevel); + +// ═══ Shop ═══ +public record UpdateShopRequest(string? Name, string? Phone, string? Email, string? Description, string? OpenTime, string? CloseTime, string? OpenDays); +public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays); + +// ═══ Campaigns / Promotions ═══ +public record CreateCampaignRequest(string Name, string? Description, decimal FaceValue, int TotalVouchers, DateTime StartDate, DateTime EndDate); + +// ═══ Membership ═══ +public record CreateMemberRequest(string? Gender, string? CountryCode); +public record UpdateMemberRequest(string? Gender, string? Preferences); + +// ═══ F&B — Tables ═══ +public record CreateTableRequest(Guid ShopId, string TableNumber, int Capacity, string? Zone); + +// ═══ Booking — Appointments ═══ +public record CreateAppointmentRequest(Guid ShopId, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid? ServiceId, DateTime StartTime, DateTime EndTime, string? Status = null); + +// ═══ Booking — Resources ═══ +public record CreateResourceRequest(Guid ShopId, string Name, string ResourceType, int Capacity); + +// ═══ F&B — Kitchen ═══ +public record UpdateTicketStatusRequest(string Status); + +// ═══ F&B — Recipes ═══ +public record CreateRecipeRequest(Guid ShopId, Guid ProductId, string Name, string? Instructions, int PrepTimeMinutes, List? Ingredients); +public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit); + +// ═══ Reports (unused DTO kept for reference) ═══ +public record TopProductItem(string ProductName, long TotalSold, decimal TotalRevenue); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs index 55181ab0..1b72b714 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs @@ -89,6 +89,12 @@ builder.Services.AddHealthChecks(); // VI: Thêm MVC controllers cho BFF data endpoints builder.Services.AddControllers(); +// EN: Register BFF infrastructure services (connection factory, tenant context) +// VI: Đăng ký services hạ tầng BFF (connection factory, tenant context) +builder.Services.AddHttpContextAccessor(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + var app = builder.Build(); // ═══════════════════════════════════════════════════════════════════════════════