From 9be3450ab977afbaac97ea825cd52bbb6ff7615e Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 4 Mar 2026 10:13:28 +0700 Subject: [PATCH] refactor(web-client-tpos): convert BFF from direct DB to HTTP proxy - Replace Dapper/Npgsql direct DB access with HttpClient proxy to microservice APIs - Create BffHttpClient.cs with AuthForwardingHandler (forwards JWT tokens) - Register 9 named HttpClients: Merchant, Catalog, Order, Inventory, Membership, Wallet, Promotion, Booking, FnbEngine - Delete BffDbConnectionFactory.cs and TenantContext.cs (no more direct DB) - Remove Dapper and Npgsql package references from .csproj - All 10 controllers are now thin HTTP proxy bridges - Zero breaking changes: all api/bff/ routes preserved --- .../Controllers/BookingController.cs | 155 ++------ .../Controllers/CatalogController.cs | 240 +++---------- .../Controllers/FinancialController.cs | 168 ++------- .../Controllers/FnbController.cs | 244 ++++--------- .../Controllers/InventoryController.cs | 101 +----- .../Controllers/MembershipController.cs | 92 ++--- .../Controllers/OrderController.cs | 337 ++---------------- .../Controllers/ReportsController.cs | 91 +---- .../Controllers/ShopController.cs | 242 ++----------- .../Controllers/StaffController.cs | 205 ++--------- .../Infrastructure/BffDbConnectionFactory.cs | 34 -- .../Infrastructure/BffHttpClient.cs | 54 +++ .../Infrastructure/TenantContext.cs | 135 ------- .../src/WebClientTpos.Server/Program.cs | 30 +- .../WebClientTpos.Server.csproj | 2 - 15 files changed, 399 insertions(+), 1731 deletions(-) delete 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/BffHttpClient.cs delete mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/TenantContext.cs 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 index 233c4995..5fbb5d36 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BookingController.cs @@ -1,25 +1,22 @@ +using System.Text.Json; 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. +/// EN: Booking controller — proxies to BookingService for appointments and resources. +/// VI: Controller đặt lịch — proxy đến BookingService cho lịch hẹn và tài nguyên. /// [ApiController] [Route("api/bff")] public class BookingController : ControllerBase { - private readonly TenantContext _tenant; - private readonly BffDbConnectionFactory _dbFactory; + private readonly HttpClient _booking; - public BookingController(TenantContext tenant, BffDbConnectionFactory dbFactory) + public BookingController(IHttpClientFactory httpClientFactory) { - _tenant = tenant; - _dbFactory = dbFactory; + _booking = httpClientFactory.CreateClient("BookingService"); } /// @@ -27,146 +24,62 @@ public class BookingController : ControllerBase /// 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); - } + public Task GetAppointments(Guid shopId) => + _booking.GetAsync($"/api/v1/appointments?shopId={shopId}").ProxyAsync(); /// - /// EN: Create an appointment — validates shop ownership. - /// VI: Tạo lịch hẹn — kiểm tra quyền sở hữu shop. + /// EN: Create an appointment. + /// VI: Tạo lịch hẹn. /// [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 }); - } + public Task CreateAppointment([FromBody] JsonElement body) => + _booking.PostAsJsonAsync("/api/v1/appointments", body).ProxyAsync(); /// - /// 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. + /// EN: Update an appointment. + /// VI: Cập nhật lịch hẹn. /// [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(); - } + public Task UpdateAppointment(Guid apptId, [FromBody] JsonElement body) => + _booking.PutAsJsonAsync($"/api/v1/appointments/{apptId}", body).ProxyAsync(); /// - /// EN: Cancel an appointment — validates shop ownership. - /// VI: Hủy lịch hẹn — kiểm tra quyền sở hữu shop. + /// EN: Cancel an appointment. + /// VI: Hủy lịch hẹn. /// [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(); - } + public Task CancelAppointment(Guid apptId) => + _booking.DeleteAsync($"/api/v1/appointments/{apptId}/cancel").ProxyAsync(); /// /// 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); - } + public Task GetResources(Guid shopId) => + _booking.GetAsync($"/api/v1/resources?shopId={shopId}").ProxyAsync(); /// - /// EN: Create a resource — validates shop ownership. - /// VI: Tạo tài nguyên — kiểm tra quyền sở hữu shop. + /// EN: Create a resource. + /// VI: Tạo tài nguyên. /// [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 }); - } + public Task CreateResource([FromBody] JsonElement body) => + _booking.PostAsJsonAsync("/api/v1/resources", body).ProxyAsync(); /// - /// 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. + /// EN: Update a resource. + /// VI: Cập nhật tài nguyên. /// [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(); - } + public Task UpdateResource(Guid resourceId, [FromBody] JsonElement body) => + _booking.PutAsJsonAsync($"/api/v1/resources/{resourceId}", body).ProxyAsync(); /// - /// 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. + /// EN: Soft-delete a resource. + /// VI: Xóa mềm tài nguyên. /// [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(); - } + public Task DeleteResource(Guid resourceId) => + _booking.DeleteAsync($"/api/v1/resources/{resourceId}").ProxyAsync(); } 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 index 11d4cee5..7cfbea2e 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/CatalogController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/CatalogController.cs @@ -1,25 +1,22 @@ +using System.Text.Json; 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. +/// EN: Catalog controller — proxies to CatalogService for product/category CRUD. +/// VI: Controller danh mục — proxy đến CatalogService cho CRUD sản phẩm/danh mục. /// [ApiController] [Route("api/bff")] public class CatalogController : ControllerBase { - private readonly TenantContext _tenant; - private readonly BffDbConnectionFactory _dbFactory; + private readonly HttpClient _catalog; - public CatalogController(TenantContext tenant, BffDbConnectionFactory dbFactory) + public CatalogController(IHttpClientFactory httpClientFactory) { - _tenant = tenant; - _dbFactory = dbFactory; + _catalog = httpClientFactory.CreateClient("CatalogService"); } /// @@ -27,32 +24,10 @@ public class CatalogController : ControllerBase /// 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) + public 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); + var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; + return _catalog.GetAsync($"/api/v1/products{qs}").ProxyAsync(); } /// @@ -60,131 +35,42 @@ public class CatalogController : ControllerBase /// 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); - } + public Task GetShopProducts(Guid shopId) => + _catalog.GetAsync($"/api/v1/shops/{shopId}/products").ProxyAsync(); /// - /// 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. + /// EN: Create a product. + /// VI: Tạo sản phẩm. /// [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 }); - } + public Task CreateProduct([FromBody] JsonElement body) => + _catalog.PostAsJsonAsync("/api/v1/products", body).ProxyAsync(); /// - /// 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. + /// EN: Update a product. + /// VI: Cập nhật sản phẩm. /// [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(); - } + public Task UpdateProduct(Guid productId, [FromBody] JsonElement body) => + _catalog.PutAsJsonAsync($"/api/v1/products/{productId}", body).ProxyAsync(); /// - /// 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. + /// EN: Delete (deactivate) a product. + /// VI: Xóa (vô hiệu hóa) sản phẩm. /// [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(); - } + public Task DeleteProduct(Guid productId) => + _catalog.DeleteAsync($"/api/v1/products/{productId}").ProxyAsync(); /// /// 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) + public 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); + var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; + return _catalog.GetAsync($"/api/v1/categories{qs}").ProxyAsync(); } /// @@ -192,76 +78,30 @@ public class CatalogController : ControllerBase /// 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); - } + public Task GetShopCategories(Guid shopId) => + _catalog.GetAsync($"/api/v1/shops/{shopId}/categories").ProxyAsync(); /// - /// EN: Create a category — validates shop ownership. - /// VI: Tạo danh mục — kiểm tra quyền sở hữu shop. + /// EN: Create a category. + /// VI: Tạo danh mục. /// [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 }); - } + public Task CreateCategory([FromBody] JsonElement body) => + _catalog.PostAsJsonAsync("/api/v1/categories", body).ProxyAsync(); /// - /// EN: Update a category — validates shop ownership. - /// VI: Cập nhật danh mục — kiểm tra quyền sở hữu shop. + /// EN: Update a category. + /// VI: Cập nhật danh mục. /// [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(); - } + public Task UpdateCategory(Guid categoryId, [FromBody] JsonElement body) => + _catalog.PutAsJsonAsync($"/api/v1/categories/{categoryId}", body).ProxyAsync(); /// - /// 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. + /// EN: Soft-delete a category. + /// VI: Xóa mềm danh mục. /// [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(); - } + public Task DeleteCategory(Guid categoryId) => + _catalog.DeleteAsync($"/api/v1/categories/{categoryId}").ProxyAsync(); } 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 index 9b52714f..78da08e0 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FinancialController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FinancialController.cs @@ -1,179 +1,71 @@ +using System.Text.Json; 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. +/// EN: Financial controller — proxies to WalletService (wallets/transactions) and PromotionService (campaigns). +/// VI: Controller tài chính — proxy đến WalletService (ví/giao dịch) và PromotionService (chiến dịch). /// [ApiController] [Route("api/bff")] public class FinancialController : ControllerBase { - private readonly TenantContext _tenant; - private readonly BffDbConnectionFactory _dbFactory; + private readonly HttpClient _wallet; + private readonly HttpClient _promotion; - public FinancialController(TenantContext tenant, BffDbConnectionFactory dbFactory) + public FinancialController(IHttpClientFactory httpClientFactory) { - _tenant = tenant; - _dbFactory = dbFactory; + _wallet = httpClientFactory.CreateClient("WalletService"); + _promotion = httpClientFactory.CreateClient("PromotionService"); } /// - /// 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. + /// EN: Get wallets for the current merchant. + /// VI: Lấy ví của merchant hiện tại. /// [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()); - } - } + public Task GetWallets() => + _wallet.GetAsync("/api/v1/wallets").ProxyAsync(); /// /// 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()); - } - } + public Task GetWalletTransactions([FromQuery] int limit = 50) => + _wallet.GetAsync($"/api/v1/wallet/transactions?limit={limit}").ProxyAsync(); /// - /// 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. + /// EN: Get campaigns for current merchant. + /// VI: Lấy danh sách chiến dịch của merchant hiện tại. /// [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()); } - } + public Task GetPromotions() => + _promotion.GetAsync("/api/v1/promotions").ProxyAsync(); /// - /// EN: Create a campaign — validates merchant ownership. - /// VI: Tạo chiến dịch — kiểm tra quyền sở hữu merchant. + /// EN: Create a campaign. + /// VI: Tạo chiến dịch. /// [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 }); - } + public Task CreateCampaign([FromBody] JsonElement body) => + _promotion.PostAsJsonAsync("/api/v1/campaigns", body).ProxyAsync(); /// - /// 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. + /// EN: Update a campaign. + /// VI: Cập nhật chiến dịch. /// [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(); - } + public Task UpdateCampaign(Guid campaignId, [FromBody] JsonElement body) => + _promotion.PutAsJsonAsync($"/api/v1/campaigns/{campaignId}", body).ProxyAsync(); /// - /// 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. + /// EN: Disable a campaign (soft-delete). + /// VI: Vô hiệu hóa chiến dịch (xóa mềm). /// [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(); - } + public Task DeleteCampaign(Guid campaignId) => + _promotion.DeleteAsync($"/api/v1/campaigns/{campaignId}").ProxyAsync(); } 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 index 36c1187d..e21afcf1 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs @@ -1,213 +1,101 @@ +using System.Text.Json; 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. +/// EN: F&B controller — proxies to FnbEngine for tables, kitchen tickets, and recipes. +/// VI: Controller F&B — proxy đến FnbEngine cho bàn ăn, phiếu bếp và công thức. /// [ApiController] [Route("api/bff")] public class FnbController : ControllerBase { - private readonly TenantContext _tenant; - private readonly BffDbConnectionFactory _dbFactory; + private readonly HttpClient _fnb; - public FnbController(TenantContext tenant, BffDbConnectionFactory dbFactory) + public FnbController(IHttpClientFactory httpClientFactory) { - _tenant = tenant; - _dbFactory = dbFactory; + _fnb = httpClientFactory.CreateClient("FnbEngine"); } + /// + /// EN: Get tables for a specific shop. + /// VI: Lấy danh sách bàn của một cửa hàng. + /// [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); - } + public Task GetTables(Guid shopId) => + _fnb.GetAsync($"/api/v1/tables?shopId={shopId}").ProxyAsync(); + /// + /// EN: Create a table. + /// VI: Tạo bàn. + /// [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 }); - } + public Task CreateTable([FromBody] JsonElement body) => + _fnb.PostAsJsonAsync("/api/v1/tables", body).ProxyAsync(); + /// + /// EN: Update a table. + /// VI: Cập nhật bàn. + /// [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(); - } + public Task UpdateTable(Guid tableId, [FromBody] JsonElement body) => + _fnb.PutAsJsonAsync($"/api/v1/tables/{tableId}", body).ProxyAsync(); + /// + /// EN: Delete a table. + /// VI: Xóa bàn. + /// [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(); - } + public Task DeleteTable(Guid tableId) => + _fnb.DeleteAsync($"/api/v1/tables/{tableId}").ProxyAsync(); + /// + /// EN: Get kitchen tickets for a shop — optionally filtered by status. + /// VI: Lấy phiếu bếp của shop — tùy chọn lọc theo trạng thái. + /// [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()); } - } + public Task GetKitchenTickets(Guid shopId, [FromQuery] string status = "pending") => + _fnb.GetAsync($"/api/v1/kitchen/tickets?shopId={shopId}&status={Uri.EscapeDataString(status)}").ProxyAsync(); + /// + /// EN: Update kitchen ticket status. + /// VI: Cập nhật trạng thái phiếu bếp. + /// [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 }); } - } + public Task UpdateTicketStatus(Guid ticketId, [FromBody] JsonElement body) => + _fnb.PutAsJsonAsync($"/api/v1/kitchen/tickets/{ticketId}/status", body).ProxyAsync(); + /// + /// EN: Get recipes for a specific shop. + /// VI: Lấy công thức của một cửa hàng. + /// [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()); } - } + public Task GetRecipes(Guid shopId) => + _fnb.GetAsync($"/api/v1/kitchen/recipes?shopId={shopId}").ProxyAsync(); + /// + /// EN: Create a recipe with ingredients. + /// VI: Tạo công thức với nguyên liệu. + /// [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 }); - } + public Task CreateRecipe([FromBody] JsonElement body) => + _fnb.PostAsJsonAsync("/api/v1/kitchen/recipes", body).ProxyAsync(); + /// + /// EN: Update a recipe. + /// VI: Cập nhật công thức. + /// [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 }); - } + public Task UpdateRecipe(Guid recipeId, [FromBody] JsonElement body) => + _fnb.PutAsJsonAsync($"/api/v1/kitchen/recipes/{recipeId}", body).ProxyAsync(); + /// + /// EN: Soft-delete a recipe. + /// VI: Xóa mềm công thức. + /// [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(); - } + public Task DeleteRecipe(Guid recipeId) => + _fnb.DeleteAsync($"/api/v1/kitchen/recipes/{recipeId}").ProxyAsync(); } 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 index 12bd25b5..67bc8017 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/InventoryController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/InventoryController.cs @@ -1,69 +1,33 @@ +using System.Text.Json; 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. +/// EN: Inventory controller — proxies to InventoryService for inventory items and transactions. +/// VI: Controller tồn kho — proxy đến InventoryService cho mặt hàng và giao dịch tồn kho. /// [ApiController] [Route("api/bff")] public class InventoryController : ControllerBase { - private readonly TenantContext _tenant; - private readonly BffDbConnectionFactory _dbFactory; + private readonly HttpClient _inventory; - public InventoryController(TenantContext tenant, BffDbConnectionFactory dbFactory) + public InventoryController(IHttpClientFactory httpClientFactory) { - _tenant = tenant; - _dbFactory = dbFactory; + _inventory = httpClientFactory.CreateClient("InventoryService"); } /// - /// 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). + /// EN: Get inventory items with product names (enriched by microservice). + /// VI: Lấy danh sách tồn kho với tên sản phẩm (được bổ sung bởi microservice). /// [HttpGet("inventory")] - public async Task GetInventory([FromQuery] Guid? shopId = null) + public 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); + var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; + return _inventory.GetAsync($"/api/v1/inventory{qs}").ProxyAsync(); } /// @@ -71,50 +35,17 @@ public class InventoryController : ControllerBase /// 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(); - } + public Task UpdateInventory(Guid inventoryId, [FromBody] JsonElement body) => + _inventory.PutAsJsonAsync($"/api/v1/inventory/{inventoryId}", body).ProxyAsync(); /// /// 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) + public 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); + var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; + return _inventory.GetAsync($"/api/v1/inventory/transactions{qs}").ProxyAsync(); } } 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 index 392f78b4..ef395fe3 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/MembershipController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/MembershipController.cs @@ -1,25 +1,22 @@ +using System.Text.Json; 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. +/// EN: Membership controller — proxies to MembershipService for members and levels. +/// VI: Controller thành viên — proxy đến MembershipService cho thành viên và cấp bậc. /// [ApiController] [Route("api/bff")] public class MembershipController : ControllerBase { - private readonly TenantContext _tenant; - private readonly BffDbConnectionFactory _dbFactory; + private readonly HttpClient _membership; - public MembershipController(TenantContext tenant, BffDbConnectionFactory dbFactory) + public MembershipController(IHttpClientFactory httpClientFactory) { - _tenant = tenant; - _dbFactory = dbFactory; + _membership = httpClientFactory.CreateClient("MembershipService"); } /// @@ -27,87 +24,38 @@ public class MembershipController : ControllerBase /// 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()); } - } + public Task GetMembers() => + _membership.GetAsync("/api/v1/members").ProxyAsync(); /// - /// 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ý. + /// EN: Create a member. + /// VI: Tạo thành viên. /// [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 }); - } + public Task CreateMember([FromBody] JsonElement body) => + _membership.PostAsJsonAsync("/api/v1/members", body).ProxyAsync(); /// - /// 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. + /// EN: Update a member. + /// VI: Cập nhật 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(); - } + public Task UpdateMember(Guid memberId, [FromBody] JsonElement body) => + _membership.PutAsJsonAsync($"/api/v1/members/{memberId}", body).ProxyAsync(); /// /// 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(); - } + public Task DeleteMember(Guid memberId) => + _membership.DeleteAsync($"/api/v1/members/{memberId}").ProxyAsync(); /// /// 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()); } - } + public Task GetMembershipLevels() => + _membership.GetAsync("/api/v1/levels").ProxyAsync(); } 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 index 1898628b..59a782f7 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs @@ -1,28 +1,22 @@ +using System.Text.Json; 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. +/// EN: Order controller — proxies to OrderService for orders, POS checkout, and dashboard. +/// VI: Controller đơn hàng — proxy đến OrderService cho đơn hàng, POS thanh toán, dashboard. /// [ApiController] [Route("api/bff")] public class OrderController : ControllerBase { - private readonly TenantContext _tenant; - private readonly BffDbConnectionFactory _dbFactory; + private readonly HttpClient _order; - public OrderController(TenantContext tenant, BffDbConnectionFactory dbFactory) + public OrderController(IHttpClientFactory httpClientFactory) { - _tenant = tenant; - _dbFactory = dbFactory; + _order = httpClientFactory.CreateClient("OrderService"); } /// @@ -30,322 +24,49 @@ public class OrderController : ControllerBase /// 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( + public 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); + var qs = new List(); + if (shopId.HasValue) qs.Add($"shopId={shopId}"); + if (!string.IsNullOrEmpty(filter)) qs.Add($"filter={Uri.EscapeDataString(filter)}"); + var query = qs.Count > 0 ? "?" + string.Join("&", qs) : ""; + return _order.GetAsync($"/api/v1/orders{query}").ProxyAsync(); } /// - /// 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. + /// EN: Get full order detail with items. + /// VI: Lấy chi tiết đơn hàng kèm items. /// [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 }); - } + public Task GetOrderDetail(Guid orderId) => + _order.GetAsync($"/api/v1/orders/{orderId}").ProxyAsync(); /// - /// 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. + /// EN: Cancel an order. + /// VI: Hủy đơn hàng. /// [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." }); - } + public Task CancelOrder(Guid orderId) => + _order.PutAsync($"/api/v1/orders/{orderId}/cancel", null).ProxyAsync(); /// - /// 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. + /// EN: Create a POS order. + /// VI: Tạo đơn POS. /// [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 }); - } - } + public Task CreatePosOrder([FromBody] JsonElement body) => + _order.PostAsJsonAsync("/api/v1/pos/orders", body).ProxyAsync(); /// - /// 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ờ. + /// EN: Get POS dashboard data — daily revenue, order count, popular items. + /// VI: Lấy dữ liệu dashboard POS — doanh thu ngày, số đơn, món bán chạy. /// [HttpGet("pos/dashboard")] - public async Task GetPosDashboard([FromQuery] Guid? shopId = null) + public 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 - }); + var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; + return _order.GetAsync($"/api/v1/pos/dashboard{qs}").ProxyAsync(); } - - 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 index f0b17474..20b221fb 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ReportsController.cs @@ -1,105 +1,48 @@ 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. +/// EN: Reports controller — proxies to OrderService for revenue reports and top products. +/// VI: Controller báo cáo — proxy đến OrderService cho báo cáo doanh thu và sản phẩm bán chạy. /// [ApiController] [Route("api/bff")] public class ReportsController : ControllerBase { - private readonly TenantContext _tenant; - private readonly BffDbConnectionFactory _dbFactory; + private readonly HttpClient _order; - public ReportsController(TenantContext tenant, BffDbConnectionFactory dbFactory) + public ReportsController(IHttpClientFactory httpClientFactory) { - _tenant = tenant; - _dbFactory = dbFactory; + _order = httpClientFactory.CreateClient("OrderService"); } /// - /// 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. + /// EN: Get revenue report grouped by day/week/month. + /// VI: Lấy báo cáo doanh thu theo ngày/tuần/tháng. /// [HttpGet("reports/revenue")] - public async Task GetRevenueReport( + public 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()); } + var qs = new List { $"period={Uri.EscapeDataString(period)}" }; + if (shopId.HasValue) qs.Add($"shopId={shopId}"); + return _order.GetAsync($"/api/v1/reports/revenue?{string.Join("&", qs)}").ProxyAsync(); } /// - /// 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. + /// EN: Get top-selling products. + /// VI: Lấy sản phẩm bán chạy nhất. /// [HttpGet("reports/top-products")] - public async Task GetTopProducts( + public 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()); } + var qs = new List { $"limit={limit}" }; + if (shopId.HasValue) qs.Add($"shopId={shopId}"); + return _order.GetAsync($"/api/v1/reports/top-products?{string.Join("&", qs)}").ProxyAsync(); } } 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 index 23222d19..277b68b9 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs @@ -1,27 +1,22 @@ +using System.Text.Json; 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. +/// EN: Shop management controller — proxies to MerchantService for shop CRUD, settings, stats, devices. +/// VI: Controller quản lý cửa hàng — proxy đến MerchantService cho CRUD shop, settings, stats, devices. /// [ApiController] [Route("api/bff")] public class ShopController : ControllerBase { - private readonly TenantContext _tenant; - private readonly BffDbConnectionFactory _dbFactory; + private readonly HttpClient _merchant; - public ShopController(TenantContext tenant, BffDbConnectionFactory dbFactory) + public ShopController(IHttpClientFactory httpClientFactory) { - _tenant = tenant; - _dbFactory = dbFactory; + _merchant = httpClientFactory.CreateClient("MerchantService"); } /// @@ -29,231 +24,54 @@ public class ShopController : ControllerBase /// 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); - } + public Task GetShops() => + _merchant.GetAsync("/api/v1/shops").ProxyAsync(); /// - /// 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. + /// EN: Get shop by ID. + /// VI: Lấy cửa hàng theo ID. /// [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); - } + public Task GetShopById(Guid shopId) => + _merchant.GetAsync($"/api/v1/shops/{shopId}").ProxyAsync(); /// - /// 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. + /// EN: Update shop info. + /// VI: Cập nhật thông tin cửa hàng. /// [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(); - } + public Task UpdateShop(Guid shopId, [FromBody] JsonElement body) => + _merchant.PutAsJsonAsync($"/api/v1/shops/{shopId}", body).ProxyAsync(); /// - /// 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). + /// EN: Get shop settings. + /// VI: Lấy cài đặt cửa hàng. /// [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); - } + public Task GetShopSettings(Guid shopId) => + _merchant.GetAsync($"/api/v1/shops/{shopId}/settings").ProxyAsync(); /// - /// 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. + /// EN: Update shop settings. + /// VI: Cập nhật cài đặt cửa hàng. /// [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 }); } - } + public Task UpdateShopSettings(Guid shopId, [FromBody] JsonElement body) => + _merchant.PutAsJsonAsync($"/api/v1/shops/{shopId}/settings", body).ProxyAsync(); /// - /// 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. + /// EN: Get aggregated stats per shop. + /// VI: Lấy thống kê tổng hợp theo shop. /// [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); - } + public Task GetShopStats() => + _merchant.GetAsync("/api/v1/shops/stats").ProxyAsync(); /// /// 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); - } + public Task GetDevices() => + _merchant.GetAsync("/api/v1/devices").ProxyAsync(); } 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 index 50305894..4b83cbe3 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs @@ -1,25 +1,22 @@ +using System.Text.Json; 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. +/// EN: Staff controller — proxies to MerchantService for staff/roles/schedules CRUD. +/// VI: Controller nhân viên — proxy đến MerchantService cho CRUD nhân viên/vai trò/lịch. /// [ApiController] [Route("api/bff")] public class StaffController : ControllerBase { - private readonly TenantContext _tenant; - private readonly BffDbConnectionFactory _dbFactory; + private readonly HttpClient _merchant; - public StaffController(TenantContext tenant, BffDbConnectionFactory dbFactory) + public StaffController(IHttpClientFactory httpClientFactory) { - _tenant = tenant; - _dbFactory = dbFactory; + _merchant = httpClientFactory.CreateClient("MerchantService"); } /// @@ -27,201 +24,73 @@ public class StaffController : ControllerBase /// 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); - } + public Task GetStaff() => + _merchant.GetAsync("/api/v1/merchants/me/staff").ProxyAsync(); /// - /// 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. + /// EN: Create a staff member. + /// VI: Tạo nhân viên mới. /// [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 }); - } + public Task CreateStaff([FromBody] JsonElement body) => + _merchant.PostAsJsonAsync("/api/v1/merchants/me/staff", body).ProxyAsync(); /// - /// 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. + /// EN: Update a staff member. + /// VI: Cập nhật nhân viên. /// [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(); - } + public Task UpdateStaff(Guid staffId, [FromBody] JsonElement body) => + _merchant.PutAsJsonAsync($"/api/v1/merchants/me/staff/{staffId}", body).ProxyAsync(); /// /// 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(); - } + public Task DeleteStaff(Guid staffId) => + _merchant.DeleteAsync($"/api/v1/merchants/me/staff/{staffId}").ProxyAsync(); /// /// 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()); } - } + public Task GetStaffRoles() => + _merchant.GetAsync("/api/v1/merchants/me/staff/roles").ProxyAsync(); /// - /// 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. + /// EN: Get staff schedules — optionally filtered by shopId. + /// VI: Lấy lịch làm việc nhân viên — tùy chọn lọc theo shopId. /// [HttpGet("staff/schedules")] - public async Task GetStaffSchedules([FromQuery] Guid? shopId = null) + public 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()); } + var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; + return _merchant.GetAsync($"/api/v1/merchants/me/staff/schedules{qs}").ProxyAsync(); } /// - /// 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. + /// EN: Create a staff schedule. + /// VI: Tạo lịch làm việc nhân viên. /// [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 }); - } + public Task CreateSchedule([FromBody] JsonElement body) => + _merchant.PostAsJsonAsync("/api/v1/merchants/me/staff/schedules", body).ProxyAsync(); /// - /// 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. + /// EN: Update a staff schedule. + /// VI: Cập nhật lịch làm việc nhân viên. /// [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(); - } + public Task UpdateSchedule(Guid scheduleId, [FromBody] JsonElement body) => + _merchant.PutAsJsonAsync($"/api/v1/merchants/me/staff/schedules/{scheduleId}", body).ProxyAsync(); /// - /// 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. + /// EN: Delete a staff schedule. + /// VI: Xóa lịch làm việc nhân viên. /// [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(); - } + public Task DeleteSchedule(Guid scheduleId) => + _merchant.DeleteAsync($"/api/v1/merchants/me/staff/schedules/{scheduleId}").ProxyAsync(); } 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 deleted file mode 100644 index db180cdd..00000000 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/BffDbConnectionFactory.cs +++ /dev/null @@ -1,34 +0,0 @@ -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/BffHttpClient.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/BffHttpClient.cs new file mode 100644 index 00000000..dd017884 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/BffHttpClient.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc; + +namespace WebClientTpos.Server.Infrastructure; + +/// +/// EN: DelegatingHandler that forwards the Authorization header from incoming HTTP requests +/// to outgoing HttpClient requests for transparent auth forwarding to microservices. +/// VI: DelegatingHandler chuyển tiếp header Authorization từ request HTTP đến +/// sang các request HttpClient gửi đi, cho phép chuyển tiếp auth trong suốt đến microservices. +/// +public class AuthForwardingHandler : DelegatingHandler +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public AuthForwardingHandler(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var incomingRequest = _httpContextAccessor.HttpContext?.Request; + if (incomingRequest?.Headers.ContainsKey("Authorization") == true) + { + request.Headers.TryAddWithoutValidation( + "Authorization", incomingRequest.Headers["Authorization"].ToString()); + } + return base.SendAsync(request, cancellationToken); + } +} + +/// +/// EN: Extension methods for proxying HttpResponseMessage to IActionResult. +/// VI: Extension methods để proxy HttpResponseMessage thành IActionResult. +/// +public static class HttpProxyExtensions +{ + /// + /// EN: Proxy an HttpResponseMessage to an IActionResult, preserving status code and content. + /// VI: Proxy HttpResponseMessage thành IActionResult, giữ nguyên status code và content. + /// + public static async Task ProxyAsync(this Task responseTask) + { + var response = await responseTask; + var content = await response.Content.ReadAsStringAsync(); + return new ContentResult + { + StatusCode = (int)response.StatusCode, + Content = content, + ContentType = response.Content.Headers.ContentType?.ToString() ?? "application/json" + }; + } +} 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 deleted file mode 100644 index a5d5bf47..00000000 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Infrastructure/TenantContext.cs +++ /dev/null @@ -1,135 +0,0 @@ -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/Program.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs index 1b72b714..bdfec23b 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Program.cs @@ -1,6 +1,8 @@ /// /// EN: ASP.NET Core BFF (Backend for Frontend) with YARP Reverse Proxy. +/// BFF controllers proxy to microservices via HttpClient (no direct DB access). /// VI: ASP.NET Core BFF (Backend for Frontend) với YARP Reverse Proxy. +/// BFF controllers proxy đến microservices qua HttpClient (không truy cập DB trực tiếp). /// using Microsoft.AspNetCore.Rewrite; @@ -89,11 +91,31 @@ 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) +// EN: Register BFF infrastructure — AuthForwardingHandler + named HttpClients for microservices +// VI: Đăng ký hạ tầng BFF — AuthForwardingHandler + named HttpClients cho microservices builder.Services.AddHttpContextAccessor(); -builder.Services.AddSingleton(); -builder.Services.AddScoped(); +builder.Services.AddTransient(); + +// EN: Register named HttpClients with auth forwarding for each microservice +// VI: Đăng ký named HttpClients với chuyển tiếp auth cho mỗi microservice +void AddServiceClient(string name, string envVar, string defaultUrl) +{ + builder.Services.AddHttpClient(name, client => + { + client.BaseAddress = new Uri( + Environment.GetEnvironmentVariable(envVar) ?? defaultUrl); + }).AddHttpMessageHandler(); +} + +AddServiceClient("MerchantService", "MerchantService__BaseUrl", "http://localhost:5002"); +AddServiceClient("CatalogService", "CatalogService__BaseUrl", "http://localhost:5016"); +AddServiceClient("OrderService", "OrderService__BaseUrl", "http://localhost:5017"); +AddServiceClient("InventoryService", "InventoryService__BaseUrl", "http://localhost:5018"); +AddServiceClient("MembershipService", "MembershipService__BaseUrl", "http://localhost:5003"); +AddServiceClient("WalletService", "WalletService__BaseUrl", "http://localhost:5004"); +AddServiceClient("PromotionService", "PromotionService__BaseUrl", "http://localhost:5008"); +AddServiceClient("BookingService", "BookingService__BaseUrl", "http://localhost:5020"); +AddServiceClient("FnbEngine", "FnbEngine__BaseUrl", "http://localhost:5019"); var app = builder.Build(); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/WebClientTpos.Server.csproj b/apps/web-client-tpos-net/src/WebClientTpos.Server/WebClientTpos.Server.csproj index bdba6648..6937a70c 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/WebClientTpos.Server.csproj +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/WebClientTpos.Server.csproj @@ -7,11 +7,9 @@ - -