refactor(web-client-tpos): split monolithic BffDataController into 10 module controllers

- Extract shared infrastructure: BffDbConnectionFactory, TenantContext (per-request cache)
- Extract 19 DTOs to Models/BffDtos.cs
- Create 10 controllers: Shop, Catalog, Staff, Order, Inventory, Financial, Booking, Fnb, Reports, Membership
- Register DI services in Program.cs
- Delete monolithic BffDataController.cs (1831 lines)
- All API routes preserved under api/bff prefix (zero breaking changes)
This commit is contained in:
Ho Ngoc Hai
2026-03-04 09:36:57 +07:00
parent b8b8bf1336
commit 0a5e1a9271
15 changed files with 2232 additions and 1830 deletions

View File

@@ -0,0 +1,172 @@
using Microsoft.AspNetCore.Mvc;
using Dapper;
using WebClientTpos.Server.Infrastructure;
using WebClientTpos.Server.Models;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/bff")]
public class BookingController : ControllerBase
{
private readonly TenantContext _tenant;
private readonly BffDbConnectionFactory _dbFactory;
public BookingController(TenantContext tenant, BffDbConnectionFactory dbFactory)
{
_tenant = tenant;
_dbFactory = dbFactory;
}
/// <summary>
/// EN: Get appointments for a specific shop.
/// VI: Lấy lịch hẹn của một cửa hàng cụ thể.
/// </summary>
[HttpGet("shops/{shopId}/appointments")]
public async Task<IActionResult> GetAppointments(Guid shopId)
{
await using var conn = _dbFactory.CreateConnection("booking_service");
var appointments = await conn.QueryAsync<dynamic>(
@"SELECT a.id, a.customer_id, a.staff_id, a.resource_id,
a.service_id, a.start_time, a.end_time, a.status,
r.name as resource_name
FROM appointments a
LEFT JOIN resources r ON a.resource_id = r.id
WHERE a.shop_id = @ShopId
ORDER BY a.start_time",
new { ShopId = shopId });
return Ok(appointments);
}
/// <summary>
/// EN: Create an appointment — validates shop ownership.
/// VI: Tạo lịch hẹn — kiểm tra quyền sở hữu shop.
/// </summary>
[HttpPost("appointments")]
public async Task<IActionResult> 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 });
}
/// <summary>
/// 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.
/// </summary>
[HttpPut("appointments/{apptId:guid}")]
public async Task<IActionResult> 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();
}
/// <summary>
/// EN: Cancel an appointment — validates shop ownership.
/// VI: Hủy lịch hẹn — kiểm tra quyền sở hữu shop.
/// </summary>
[HttpDelete("appointments/{apptId:guid}/cancel")]
public async Task<IActionResult> 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();
}
/// <summary>
/// EN: Get resources for a specific shop.
/// VI: Lấy tài nguyên của một cửa hàng cụ thể.
/// </summary>
[HttpGet("shops/{shopId}/resources")]
public async Task<IActionResult> GetResources(Guid shopId)
{
await using var conn = _dbFactory.CreateConnection("booking_service");
var resources = await conn.QueryAsync<dynamic>(
@"SELECT id, name, resource_type, capacity, is_active
FROM resources
WHERE shop_id = @ShopId AND is_active = true
ORDER BY name",
new { ShopId = shopId });
return Ok(resources);
}
/// <summary>
/// EN: Create a resource — validates shop ownership.
/// VI: Tạo tài nguyên — kiểm tra quyền sở hữu shop.
/// </summary>
[HttpPost("resources")]
public async Task<IActionResult> 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 });
}
/// <summary>
/// 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.
/// </summary>
[HttpPut("resources/{resourceId:guid}")]
public async Task<IActionResult> 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();
}
/// <summary>
/// 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.
/// </summary>
[HttpDelete("resources/{resourceId:guid}")]
public async Task<IActionResult> 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();
}
}

View File

@@ -0,0 +1,267 @@
using Microsoft.AspNetCore.Mvc;
using Dapper;
using WebClientTpos.Server.Infrastructure;
using WebClientTpos.Server.Models;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/bff")]
public class CatalogController : ControllerBase
{
private readonly TenantContext _tenant;
private readonly BffDbConnectionFactory _dbFactory;
public CatalogController(TenantContext tenant, BffDbConnectionFactory dbFactory)
{
_tenant = tenant;
_dbFactory = dbFactory;
}
/// <summary>
/// EN: Get products belonging to the current merchant's shops.
/// VI: Lấy sản phẩm thuộc các cửa hàng của merchant hiện tại.
/// </summary>
[HttpGet("products")]
public async Task<IActionResult> GetAllProducts([FromQuery] Guid? shopId = null)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = _dbFactory.CreateConnection("catalog_service");
var products = await conn.QueryAsync<dynamic>(
@"SELECT p.id, p.name, p.price, p.sku, p.description, p.image_url,
p.is_active, pt.name as type, p.shop_id, p.created_at,
'' as category_name
FROM products p
JOIN product_types pt ON p.type_id = pt.id
WHERE p.shop_id = ANY(@ShopIds)
ORDER BY p.name",
new { ShopIds = targetShopIds.ToArray() });
return Ok(products);
}
/// <summary>
/// EN: Get products for a specific shop.
/// VI: Lấy sản phẩm của một cửa hàng cụ thể.
/// </summary>
[HttpGet("shops/{shopId}/products")]
public async Task<IActionResult> GetShopProducts(Guid shopId)
{
await using var conn = _dbFactory.CreateConnection("catalog_service");
var products = await conn.QueryAsync<dynamic>(
@"SELECT id, name, price, sku, description, image_url, is_active,
attributes->>'category' as category,
(attributes->>'duration')::int as duration_minutes
FROM products
WHERE shop_id = @ShopId AND is_active = true
ORDER BY name",
new { ShopId = shopId });
return Ok(products);
}
/// <summary>
/// EN: Create a product — validates shop ownership first.
/// VI: Tạo sản phẩm — kiểm tra quyền sở hữu shop trước.
/// </summary>
[HttpPost("products")]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest req)
{
var merchantId = await _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 });
}
/// <summary>
/// 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.
/// </summary>
[HttpPut("products/{productId:guid}")]
public async Task<IActionResult> 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();
}
/// <summary>
/// EN: Delete (deactivate) a product — validates ownership first.
/// VI: Xóa (vô hiệu hóa) sản phẩm — kiểm tra quyền sở hữu trước.
/// </summary>
[HttpDelete("products/{productId:guid}")]
public async Task<IActionResult> DeleteProduct(Guid productId)
{
var merchantId = await _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();
}
/// <summary>
/// EN: Get categories belonging to the current merchant's shops.
/// VI: Lấy danh mục thuộc các cửa hàng của merchant hiện tại.
/// </summary>
[HttpGet("categories")]
public async Task<IActionResult> GetAllCategories([FromQuery] Guid? shopId = null)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = _dbFactory.CreateConnection("catalog_service");
var categories = await conn.QueryAsync<dynamic>(
@"SELECT id, name, description, display_order, shop_id, parent_id, is_active
FROM categories
WHERE is_active = true AND shop_id = ANY(@ShopIds)
ORDER BY display_order, name",
new { ShopIds = targetShopIds.ToArray() });
return Ok(categories);
}
/// <summary>
/// EN: Get categories for a specific shop.
/// VI: Lấy danh mục của một cửa hàng cụ thể.
/// </summary>
[HttpGet("shops/{shopId}/categories")]
public async Task<IActionResult> GetShopCategories(Guid shopId)
{
await using var conn = _dbFactory.CreateConnection("catalog_service");
var categories = await conn.QueryAsync<dynamic>(
@"SELECT id, name, description, display_order
FROM categories
WHERE shop_id = @ShopId AND is_active = true
ORDER BY display_order",
new { ShopId = shopId });
return Ok(categories);
}
/// <summary>
/// EN: Create a category — validates shop ownership.
/// VI: Tạo danh mục — kiểm tra quyền sở hữu shop.
/// </summary>
[HttpPost("categories")]
public async Task<IActionResult> 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 });
}
/// <summary>
/// EN: Update a category — validates shop ownership.
/// VI: Cập nhật danh mục — kiểm tra quyền sở hữu shop.
/// </summary>
[HttpPut("categories/{categoryId:guid}")]
public async Task<IActionResult> 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();
}
/// <summary>
/// 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.
/// </summary>
[HttpDelete("categories/{categoryId:guid}")]
public async Task<IActionResult> 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();
}
}

View File

@@ -0,0 +1,179 @@
using Microsoft.AspNetCore.Mvc;
using Dapper;
using WebClientTpos.Server.Infrastructure;
using WebClientTpos.Server.Models;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/bff")]
public class FinancialController : ControllerBase
{
private readonly TenantContext _tenant;
private readonly BffDbConnectionFactory _dbFactory;
public FinancialController(TenantContext tenant, BffDbConnectionFactory dbFactory)
{
_tenant = tenant;
_dbFactory = dbFactory;
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("wallets")]
public async Task<IActionResult> GetWallets()
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
try
{
await using var conn = _dbFactory.CreateConnection("wallet_service");
var wallets = await conn.QueryAsync<dynamic>(
@"SELECT w.id, w.balance, w.currency, w.owner_id, w.created_at,
(SELECT COALESCE(SUM(amount),0) FROM wallet_transactions wt WHERE wt.wallet_id = w.id AND wt.amount > 0) as total_income,
(SELECT COALESCE(SUM(ABS(amount)),0) FROM wallet_transactions wt WHERE wt.wallet_id = w.id AND wt.amount < 0) as total_expense
FROM wallets w
WHERE w.owner_id = @MerchantId::text
ORDER BY w.created_at DESC",
new { MerchantId = merchantId });
return Ok(wallets);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[BFF] GetWallets error: {ex.Message}");
return Ok(Array.Empty<object>());
}
}
/// <summary>
/// EN: Get wallet transactions for the current merchant.
/// VI: Lấy giao dịch ví của merchant hiện tại.
/// </summary>
[HttpGet("wallet/transactions")]
public async Task<IActionResult> GetWalletTransactions([FromQuery] int limit = 50)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
try
{
await using var conn = _dbFactory.CreateConnection("wallet_service");
var txns = await conn.QueryAsync<dynamic>(
@"SELECT wt.id, wt.wallet_id, wt.amount, wt.description, wt.created_at,
wi.name as item_name
FROM wallet_transactions wt
JOIN wallets w ON wt.wallet_id = w.id
LEFT JOIN wallet_items wi ON wt.reference_id = wi.id
WHERE w.owner_id = @MerchantId::text
ORDER BY wt.created_at DESC LIMIT @Limit",
new { MerchantId = merchantId, Limit = limit });
return Ok(txns);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[BFF] GetWalletTransactions error: {ex.Message}");
return Ok(Array.Empty<object>());
}
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("promotions")]
public async Task<IActionResult> GetPromotions()
{
try
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null) return Ok(Array.Empty<object>());
await using var conn = _dbFactory.CreateConnection("promotion_service");
var campaigns = await conn.QueryAsync<dynamic>(
@"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<object>()); }
}
/// <summary>
/// EN: Create a campaign — validates merchant ownership.
/// VI: Tạo chiến dịch — kiểm tra quyền sở hữu merchant.
/// </summary>
[HttpPost("campaigns")]
public async Task<IActionResult> 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 });
}
/// <summary>
/// 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.
/// </summary>
[HttpPut("campaigns/{campaignId:guid}")]
public async Task<IActionResult> 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();
}
/// <summary>
/// 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.
/// </summary>
[HttpDelete("campaigns/{campaignId:guid}")]
public async Task<IActionResult> 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();
}
}

View File

@@ -0,0 +1,213 @@
using Microsoft.AspNetCore.Mvc;
using Dapper;
using WebClientTpos.Server.Infrastructure;
using WebClientTpos.Server.Models;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/bff")]
public class FnbController : ControllerBase
{
private readonly TenantContext _tenant;
private readonly BffDbConnectionFactory _dbFactory;
public FnbController(TenantContext tenant, BffDbConnectionFactory dbFactory)
{
_tenant = tenant;
_dbFactory = dbFactory;
}
[HttpGet("shops/{shopId}/tables")]
public async Task<IActionResult> GetTables(Guid shopId)
{
await using var conn = _dbFactory.CreateConnection("fnb_engine");
var tables = await conn.QueryAsync<dynamic>(
@"SELECT t.id, t.table_number, t.capacity, t.zone,
CASE t.status_id
WHEN 1 THEN 'available'
WHEN 2 THEN 'occupied'
WHEN 3 THEN 'reserved'
WHEN 4 THEN 'cleaning'
END as status,
s.id as session_id, s.guest_count, s.started_at
FROM tables t
LEFT JOIN sessions s ON s.table_id = t.id AND s.status = 'Active'
WHERE t.shop_id = @ShopId
ORDER BY t.table_number",
new { ShopId = shopId });
return Ok(tables);
}
[HttpPost("tables")]
public async Task<IActionResult> CreateTable([FromBody] CreateTableRequest req)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null) return Forbid();
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Contains(req.ShopId)) return Forbid();
var id = Guid.NewGuid();
await using var conn = _dbFactory.CreateConnection("fnb_engine");
await conn.ExecuteAsync(
@"INSERT INTO tables (id, shop_id, table_number, capacity, zone, status_id, created_at, updated_at)
VALUES (@Id, @ShopId, @TableNumber, @Capacity, @Zone, 1, NOW(), NOW())",
new { Id = id, req.ShopId, req.TableNumber, req.Capacity, Zone = req.Zone ?? "" });
return StatusCode(201, new { id });
}
[HttpPut("tables/{tableId:guid}")]
public async Task<IActionResult> UpdateTable(Guid tableId, [FromBody] CreateTableRequest req)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null) return Unauthorized();
var myShopIds = await _tenant.GetShopIdsAsync();
await using var conn = _dbFactory.CreateConnection("fnb_engine");
var rows = await conn.ExecuteAsync(
@"UPDATE tables SET table_number=@TableNumber, capacity=@Capacity, zone=@Zone, updated_at=NOW()
WHERE id=@Id AND shop_id=ANY(@ShopIds)",
new { Id = tableId, req.TableNumber, req.Capacity, Zone = req.Zone ?? "", ShopIds = myShopIds.ToArray() });
return rows > 0 ? Ok(new { id = tableId }) : NotFound();
}
[HttpDelete("tables/{tableId:guid}")]
public async Task<IActionResult> DeleteTable(Guid tableId)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null) return Forbid();
var myShopIds = await _tenant.GetShopIdsAsync();
await using var conn = _dbFactory.CreateConnection("fnb_engine");
await conn.ExecuteAsync(
"DELETE FROM tables WHERE id=@Id AND shop_id=ANY(@ShopIds)",
new { Id = tableId, ShopIds = myShopIds.ToArray() });
return NoContent();
}
[HttpGet("shops/{shopId}/kitchen-tickets")]
public async Task<IActionResult> GetKitchenTickets(Guid shopId, [FromQuery] string status = "pending")
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null) return Ok(Array.Empty<object>());
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Any()) return Ok(Array.Empty<object>());
if (!myShopIds.Contains(shopId)) return Ok(Array.Empty<object>());
var targetShopIds = new List<Guid> { shopId };
try
{
await using var conn = _dbFactory.CreateConnection("fnb_engine");
var whereStatus = status == "all" ? "" : "AND kt.status=@Status";
var tickets = await conn.QueryAsync<dynamic>(
$@"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<object>()); }
}
[HttpPut("kitchen/tickets/{ticketId:guid}/status")]
public async Task<IActionResult> UpdateTicketStatus(Guid ticketId, [FromBody] UpdateTicketStatusRequest req)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null) return Unauthorized();
try
{
await using var conn = _dbFactory.CreateConnection("fnb_engine");
await conn.ExecuteAsync(
@"UPDATE kitchen_tickets SET status=@Status,
completed_at=CASE WHEN @Status='completed' THEN NOW() ELSE NULL END
WHERE id=@Id",
new { Id = ticketId, req.Status });
return Ok(new { id = ticketId });
}
catch (Exception ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("shops/{shopId}/recipes")]
public async Task<IActionResult> GetRecipes(Guid shopId)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null) return Ok(Array.Empty<object>());
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Any()) return Ok(Array.Empty<object>());
if (!myShopIds.Contains(shopId)) return Ok(Array.Empty<object>());
var targetShopIds = new List<Guid> { shopId };
try
{
await using var conn = _dbFactory.CreateConnection("catalog_service");
var recipes = await conn.QueryAsync<dynamic>(
@"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<object>()); }
}
[HttpPost("recipes")]
public async Task<IActionResult> CreateRecipe([FromBody] CreateRecipeRequest req)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null) return Forbid();
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Contains(req.ShopId)) return Forbid();
var id = Guid.NewGuid();
await using var conn = _dbFactory.CreateConnection("catalog_service");
await conn.OpenAsync();
await using var tx = await conn.BeginTransactionAsync();
await conn.ExecuteAsync(
@"INSERT INTO recipes (id, product_id, shop_id, name, instructions, prep_time_minutes, is_active, created_at, updated_at)
VALUES (@Id, @ProductId, @ShopId, @Name, @Instructions, @PrepTimeMinutes, true, NOW(), NOW())",
new { Id = id, req.ProductId, req.ShopId, req.Name, req.Instructions, req.PrepTimeMinutes }, tx);
foreach (var ing in req.Ingredients ?? new())
await conn.ExecuteAsync(
@"INSERT INTO recipe_ingredients (id, recipe_id, ingredient_name, quantity, unit, cost_per_unit, created_at)
VALUES (@Id, @RecipeId, @IngredientName, @Quantity, @Unit, @CostPerUnit, NOW())",
new { Id = Guid.NewGuid(), RecipeId = id, ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit }, tx);
await tx.CommitAsync();
return StatusCode(201, new { id });
}
[HttpPut("recipes/{recipeId:guid}")]
public async Task<IActionResult> UpdateRecipe(Guid recipeId, [FromBody] CreateRecipeRequest req)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null) return Unauthorized();
var myShopIds = await _tenant.GetShopIdsAsync();
await using var conn = _dbFactory.CreateConnection("catalog_service");
await conn.OpenAsync();
await using var tx = await conn.BeginTransactionAsync();
await conn.ExecuteAsync(
"UPDATE recipes SET name=@Name, instructions=@Instructions, prep_time_minutes=@PrepTimeMinutes, updated_at=NOW() WHERE id=@Id AND shop_id=ANY(@ShopIds)",
new { Id = recipeId, req.Name, req.Instructions, req.PrepTimeMinutes, ShopIds = myShopIds.ToArray() }, tx);
await conn.ExecuteAsync("DELETE FROM recipe_ingredients WHERE recipe_id=@Id", new { Id = recipeId }, tx);
foreach (var ing in req.Ingredients ?? new())
await conn.ExecuteAsync(
@"INSERT INTO recipe_ingredients (id, recipe_id, ingredient_name, quantity, unit, cost_per_unit, created_at)
VALUES (@Id, @RecipeId, @IngredientName, @Quantity, @Unit, @CostPerUnit, NOW())",
new { Id = Guid.NewGuid(), RecipeId = recipeId, ing.IngredientName, ing.Quantity, ing.Unit, ing.CostPerUnit }, tx);
await tx.CommitAsync();
return Ok(new { id = recipeId });
}
[HttpDelete("recipes/{recipeId:guid}")]
public async Task<IActionResult> 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();
}
}

View File

@@ -0,0 +1,120 @@
using Microsoft.AspNetCore.Mvc;
using Dapper;
using WebClientTpos.Server.Infrastructure;
using WebClientTpos.Server.Models;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/bff")]
public class InventoryController : ControllerBase
{
private readonly TenantContext _tenant;
private readonly BffDbConnectionFactory _dbFactory;
public InventoryController(TenantContext tenant, BffDbConnectionFactory dbFactory)
{
_tenant = tenant;
_dbFactory = dbFactory;
}
/// <summary>
/// EN: Get inventory items with product name (cross-DB join via subquery).
/// VI: Lấy danh sách tồn kho với tên sản phẩm (kết hợp cross-DB).
/// </summary>
[HttpGet("inventory")]
public async Task<IActionResult> GetInventory([FromQuery] Guid? shopId = null)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = _dbFactory.CreateConnection("inventory_service");
var items = await conn.QueryAsync<dynamic>(
@"SELECT id, product_id, shop_id, quantity, reorder_level, reserved_quantity, updated_at
FROM inventory_items
WHERE shop_id = ANY(@ShopIds)
ORDER BY quantity ASC",
new { ShopIds = targetShopIds.ToArray() });
await using var catConn = _dbFactory.CreateConnection("catalog_service");
var products = (await catConn.QueryAsync<dynamic>(
"SELECT id, name FROM products WHERE shop_id = ANY(@ShopIds)",
new { ShopIds = targetShopIds.ToArray() })).ToList();
var prodMap = products.ToDictionary(p => (Guid)p.id, p => (string)p.name);
var result = items.Select(i => new
{
i.id, i.product_id, i.shop_id, i.quantity, i.reorder_level, i.reserved_quantity, i.updated_at,
product_name = prodMap.TryGetValue((Guid)i.product_id, out var name) ? name : "Unknown"
});
return Ok(result);
}
/// <summary>
/// EN: Update inventory quantity for a specific item.
/// VI: Cập nhật số lượng tồn kho cho mặt hàng.
/// </summary>
[HttpPut("inventory/{inventoryId:guid}")]
public async Task<IActionResult> 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();
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("inventory/transactions")]
public async Task<IActionResult> GetInventoryTransactions([FromQuery] Guid? shopId = null)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = _dbFactory.CreateConnection("inventory_service");
var txns = await conn.QueryAsync<dynamic>(
@"SELECT it.id, it.inventory_item_id, it.quantity_change, it.reason, it.created_at,
tt.name as transaction_type
FROM inventory_transactions it
JOIN transaction_types tt ON it.type_id = tt.id
JOIN inventory_items ii ON it.inventory_item_id = ii.id
WHERE ii.shop_id = ANY(@ShopIds)
ORDER BY it.created_at DESC LIMIT 100",
new { ShopIds = targetShopIds.ToArray() });
return Ok(txns);
}
}

View File

@@ -0,0 +1,113 @@
using Microsoft.AspNetCore.Mvc;
using Dapper;
using WebClientTpos.Server.Infrastructure;
using WebClientTpos.Server.Models;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/bff")]
public class MembershipController : ControllerBase
{
private readonly TenantContext _tenant;
private readonly BffDbConnectionFactory _dbFactory;
public MembershipController(TenantContext tenant, BffDbConnectionFactory dbFactory)
{
_tenant = tenant;
_dbFactory = dbFactory;
}
/// <summary>
/// EN: Get all members (customers).
/// VI: Lấy danh sách thành viên (khách hàng).
/// </summary>
[HttpGet("members")]
public async Task<IActionResult> GetMembers()
{
try
{
await using var conn = _dbFactory.CreateConnection("membership_service");
var members = await conn.QueryAsync<dynamic>(
@"SELECT m.id, m.country_code, m.gender, m.current_exp, m.current_level,
m.total_exp_earned, m.created_at, m.preferences,
ml.name as level_name
FROM members m
LEFT JOIN membership_levels ml ON m.current_level = ml.level
WHERE m.is_deleted = false
ORDER BY m.created_at DESC");
return Ok(members);
}
catch { return Ok(Array.Empty<object>()); }
}
/// <summary>
/// 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ý.
/// </summary>
[HttpPost("members")]
public async Task<IActionResult> 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 });
}
/// <summary>
/// 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.
/// </summary>
[HttpPut("members/{memberId:guid}")]
public async Task<IActionResult> 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();
}
/// <summary>
/// EN: Soft-delete a member.
/// VI: Xóa mềm thành viên.
/// </summary>
[HttpDelete("members/{memberId:guid}")]
public async Task<IActionResult> 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();
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("membership/levels")]
public async Task<IActionResult> GetMembershipLevels()
{
try
{
await using var conn = _dbFactory.CreateConnection("membership_service");
var levels = await conn.QueryAsync<dynamic>(
@"SELECT ld.id, ld.level, ld.name, ld.min_exp, ld.max_exp,
(SELECT COUNT(*) FROM members m WHERE m.current_level = ld.level) as member_count
FROM level_definitions ld ORDER BY ld.level");
return Ok(levels);
}
catch { return Ok(Array.Empty<object>()); }
}
}

View File

@@ -0,0 +1,351 @@
using Microsoft.AspNetCore.Mvc;
using Dapper;
using System.IdentityModel.Tokens.Jwt;
using WebClientTpos.Server.Infrastructure;
using WebClientTpos.Server.Models;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/bff")]
public class OrderController : ControllerBase
{
private readonly TenantContext _tenant;
private readonly BffDbConnectionFactory _dbFactory;
public OrderController(TenantContext tenant, BffDbConnectionFactory dbFactory)
{
_tenant = tenant;
_dbFactory = dbFactory;
}
/// <summary>
/// EN: Get orders filtered by shop and date range (today/week/month).
/// VI: Lấy đơn hàng theo shop và khoảng ngày (hôm nay/tuần/tháng).
/// </summary>
[HttpGet("orders")]
public async Task<IActionResult> GetOrders(
[FromQuery] Guid? shopId = null,
[FromQuery] string? filter = "today")
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
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<dynamic>(
$@"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);
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("orders/{orderId:guid}")]
public async Task<IActionResult> 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<dynamic>(
@"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<dynamic>(
@"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 });
}
/// <summary>
/// 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.
/// </summary>
[HttpPut("orders/{orderId:guid}/cancel")]
public async Task<IActionResult> 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." });
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("pos/orders")]
public async Task<IActionResult> 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 });
}
}
/// <summary>
/// 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ờ.
/// </summary>
[HttpGet("pos/dashboard")]
public async Task<IActionResult> 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<Guid> { shopId.Value } : myShopIds;
decimal revenue = 0; int orderCount = 0; int itemsSold = 0;
List<object> popularItems = new();
List<object> paymentBreakdown = new();
List<object> hourlyRevenue = new();
List<object> recentOrders = new();
try
{
await using var conn = _dbFactory.CreateConnection("order_service");
var summary = await conn.QueryFirstOrDefaultAsync<dynamic>(
@"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<dynamic>(
@"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<dynamic>(
@"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<dynamic>(
@"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<dynamic>(
@"SELECT oi.product_name as name, SUM(oi.quantity) as qty,
SUM(oi.quantity * oi.unit_price) as revenue
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE o.shop_id = ANY(@ShopIds) AND DATE(o.created_at) = CURRENT_DATE
GROUP BY oi.product_name
ORDER BY qty DESC LIMIT 10",
new { ShopIds = targetShopIds.ToArray() });
itemsSold = (int)popular.Sum(p => (long)p.qty);
popularItems = popular.Select(p => (object)new
{
name = (string)p.name,
qty = (int)(long)p.qty,
revenue = (decimal)p.revenue
}).ToList();
}
catch { /* order_items table may not exist yet */ }
}
catch { /* order_service DB not available */ }
return Ok(new
{
revenue,
orderCount,
itemsSold,
avgOrderValue = orderCount > 0 ? revenue / orderCount : 0,
popularItems,
paymentBreakdown,
hourlyRevenue,
recentOrders
});
}
private static object EmptyDashboard() => new
{
revenue = 0m, orderCount = 0, itemsSold = 0, avgOrderValue = 0m,
popularItems = Array.Empty<object>(),
paymentBreakdown = Array.Empty<object>(),
hourlyRevenue = Array.Empty<object>(),
recentOrders = Array.Empty<object>()
};
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
};
}

View File

@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Mvc;
using Dapper;
using WebClientTpos.Server.Infrastructure;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/bff")]
public class ReportsController : ControllerBase
{
private readonly TenantContext _tenant;
private readonly BffDbConnectionFactory _dbFactory;
public ReportsController(TenantContext tenant, BffDbConnectionFactory dbFactory)
{
_tenant = tenant;
_dbFactory = dbFactory;
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("reports/revenue")]
public async Task<IActionResult> GetRevenueReport(
[FromQuery] string period = "daily",
[FromQuery] Guid? shopId = null)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null) return Ok(Array.Empty<object>());
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Any()) return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = _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<dynamic>(sql, new { ShopIds = targetShopIds.ToArray() });
return Ok(report);
}
catch { return Ok(Array.Empty<object>()); }
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("reports/top-products")]
public async Task<IActionResult> GetTopProducts(
[FromQuery] Guid? shopId = null,
[FromQuery] int limit = 10)
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null) return Ok(Array.Empty<object>());
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Any()) return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = _dbFactory.CreateConnection("order_service");
try
{
var rows = await conn.QueryAsync<dynamic>(
@"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<object>()); }
}
}

View File

@@ -0,0 +1,259 @@
using Microsoft.AspNetCore.Mvc;
using Dapper;
using WebClientTpos.Server.Infrastructure;
using WebClientTpos.Server.Models;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/bff")]
public class ShopController : ControllerBase
{
private readonly TenantContext _tenant;
private readonly BffDbConnectionFactory _dbFactory;
public ShopController(TenantContext tenant, BffDbConnectionFactory dbFactory)
{
_tenant = tenant;
_dbFactory = dbFactory;
}
/// <summary>
/// EN: Get all shops belonging to the current merchant.
/// VI: Lấy tất cả cửa hàng thuộc merchant hiện tại.
/// </summary>
[HttpGet("shops")]
public async Task<IActionResult> GetShops()
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
await using var conn = _dbFactory.CreateConnection("merchant_service");
var shops = await conn.QueryAsync<dynamic>(
@"SELECT s.id, s.name, s.slug, s.description, s.phone, s.email,
s.open_time, s.close_time, s.features_config,
bc.name as category, st.name as status
FROM shops s
JOIN business_categories bc ON s.category_id = bc.id
JOIN shop_statuses st ON s.status_id = st.id
WHERE s.merchant_id = @MerchantId AND s.is_deleted = false
ORDER BY s.name",
new { MerchantId = merchantId });
return Ok(shops);
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("shops/{shopId:guid}")]
public async Task<IActionResult> 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<dynamic>(
@"SELECT s.id, s.name, s.slug, s.description, s.phone, s.email,
s.open_time, s.close_time,
bc.name as category, st.name as status
FROM shops s
JOIN business_categories bc ON s.category_id = bc.id
JOIN shop_statuses st ON s.status_id = st.id
WHERE s.id = @ShopId AND s.merchant_id = @MerchantId AND s.is_deleted = false",
new { ShopId = shopId, MerchantId = merchantId });
if (shop == null)
return NotFound(new { message = "Shop not found" });
return Ok(shop);
}
/// <summary>
/// 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.
/// </summary>
[HttpPut("shops/{shopId:guid}")]
public async Task<IActionResult> 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();
}
/// <summary>
/// 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).
/// </summary>
[HttpGet("shops/{shopId:guid}/settings")]
public async Task<IActionResult> 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<dynamic>(
@"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);
}
/// <summary>
/// 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.
/// </summary>
[HttpPut("shops/{shopId:guid}/settings")]
public async Task<IActionResult> 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 }); }
}
/// <summary>
/// EN: Get aggregated stats per shop — scoped to current merchant.
/// VI: Lấy thống kê tổng hợp theo shop — lọc theo merchant hiện tại.
/// </summary>
[HttpGet("shops/stats")]
public async Task<IActionResult> GetShopStats()
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await _tenant.GetShopIdsAsync();
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
var shopIdsArray = myShopIds.ToArray();
// EN: Products per shop (scoped) / VI: Số sản phẩm mỗi shop
Dictionary<Guid, int> productCounts = new();
try
{
await using var catConn = _dbFactory.CreateConnection("catalog_service");
var prodStats = await catConn.QueryAsync<dynamic>(
"SELECT shop_id, COUNT(*) as cnt FROM products WHERE is_active = true AND shop_id = ANY(@ShopIds) GROUP BY shop_id",
new { ShopIds = shopIdsArray });
foreach (var ps in prodStats)
productCounts[(Guid)ps.shop_id] = (int)(long)ps.cnt;
}
catch { /* catalog_service may not have data yet */ }
// EN: Orders per shop + revenue (scoped) / VI: Số đơn + doanh thu mỗi shop
Dictionary<Guid, int> orderCounts = new();
Dictionary<Guid, decimal> revenues = new();
try
{
await using var orderConn = _dbFactory.CreateConnection("order_service");
var orderStats = await orderConn.QueryAsync<dynamic>(
"SELECT shop_id, COUNT(*) as cnt, COALESCE(SUM(total_amount), 0) as revenue FROM orders WHERE shop_id = ANY(@ShopIds) GROUP BY shop_id",
new { ShopIds = shopIdsArray });
foreach (var os in orderStats)
{
orderCounts[(Guid)os.shop_id] = (int)(long)os.cnt;
revenues[(Guid)os.shop_id] = (decimal)os.revenue;
}
}
catch { /* order_service may not have data yet */ }
// EN: Staff per shop (scoped) / VI: Số nhân viên mỗi shop
Dictionary<Guid, int> staffCounts = new();
try
{
await using var mConn = _dbFactory.CreateConnection("merchant_service");
var staffStats = await mConn.QueryAsync<dynamic>(
@"SELECT sm.shop_id, COUNT(DISTINCT sm.staff_id) as cnt
FROM shop_members sm
JOIN merchant_staff ms ON sm.staff_id = ms.id
JOIN staff_statuses ss ON ms.status_id = ss.id
WHERE ss.name = 'Active' AND sm.shop_id = ANY(@ShopIds)
GROUP BY sm.shop_id",
new { ShopIds = shopIdsArray });
foreach (var ss in staffStats)
staffCounts[(Guid)ss.shop_id] = (int)(long)ss.cnt;
}
catch { /* merchant_service may not have data yet */ }
var result = myShopIds.Select(shopId => new
{
shop_id = shopId,
product_count = productCounts.GetValueOrDefault(shopId, 0),
order_count = orderCounts.GetValueOrDefault(shopId, 0),
staff_count = staffCounts.GetValueOrDefault(shopId, 0),
revenue = revenues.GetValueOrDefault(shopId, 0m)
});
return Ok(result);
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("devices")]
public async Task<IActionResult> GetDevices()
{
await using var conn = _dbFactory.CreateConnection("merchant_service");
var devices = await conn.QueryAsync<dynamic>(
@"SELECT dt.id, dt.device_token, dt.platform, dt.is_active, dt.created_at,
ms.employee_code as staff_code
FROM device_tokens dt
LEFT JOIN merchant_staff ms ON dt.staff_id = ms.id
ORDER BY dt.created_at DESC");
return Ok(devices);
}
}

View File

@@ -0,0 +1,227 @@
using Microsoft.AspNetCore.Mvc;
using Dapper;
using WebClientTpos.Server.Infrastructure;
using WebClientTpos.Server.Models;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/bff")]
public class StaffController : ControllerBase
{
private readonly TenantContext _tenant;
private readonly BffDbConnectionFactory _dbFactory;
public StaffController(TenantContext tenant, BffDbConnectionFactory dbFactory)
{
_tenant = tenant;
_dbFactory = dbFactory;
}
/// <summary>
/// EN: Get all staff members for the current merchant.
/// VI: Lấy tất cả nhân viên của merchant hiện tại.
/// </summary>
[HttpGet("staff")]
public async Task<IActionResult> GetStaff()
{
var merchantId = await _tenant.GetMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
await using var conn = _dbFactory.CreateConnection("merchant_service");
var staff = await conn.QueryAsync<dynamic>(
@"SELECT ms.id, ms.user_id, ms.employee_code, ms.phone, ms.email,
ms.joined_at, ms.terminated_at,
sr.name as role, ss.name as status,
s.name as shop_name
FROM merchant_staff ms
JOIN staff_roles sr ON ms.role_id = sr.id
JOIN staff_statuses ss ON ms.status_id = ss.id
LEFT JOIN shop_members sm ON sm.staff_id = ms.id
LEFT JOIN shops s ON sm.shop_id = s.id
WHERE ms.merchant_id = @MerchantId
ORDER BY ms.joined_at DESC",
new { MerchantId = merchantId });
return Ok(staff);
}
/// <summary>
/// EN: Create a staff member — validates merchant ownership.
/// VI: Tạo nhân viên mới — kiểm tra quyền sở hữu merchant.
/// </summary>
[HttpPost("staff")]
public async Task<IActionResult> CreateStaff([FromBody] CreateStaffRequest req)
{
var merchantId = await _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<int>(
"SELECT id FROM staff_roles WHERE name = @Role", new { req.Role });
if (roleId == 0) roleId = 1;
var statusId = await conn.QueryFirstOrDefaultAsync<int>(
"SELECT id FROM staff_statuses WHERE name = 'Active'");
if (statusId == 0) statusId = 1;
await conn.ExecuteAsync(
@"INSERT INTO merchant_staff (id, merchant_id, employee_code, phone, email, role_id, status_id, 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 });
}
/// <summary>
/// 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.
/// </summary>
[HttpPut("staff/{staffId:guid}")]
public async Task<IActionResult> 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<int>(
"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();
}
/// <summary>
/// EN: Terminate (soft-delete) a staff member.
/// VI: Chấm dứt (xóa mềm) nhân viên.
/// </summary>
[HttpDelete("staff/{staffId:guid}")]
public async Task<IActionResult> 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<int>(
"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();
}
/// <summary>
/// EN: Get all available staff roles.
/// VI: Lấy tất cả vai trò nhân viên hiện có.
/// </summary>
[HttpGet("staff/roles")]
public async Task<IActionResult> GetStaffRoles()
{
try
{
await using var conn = _dbFactory.CreateConnection("merchant_service");
var roles = await conn.QueryAsync<dynamic>("SELECT id, name FROM staff_roles ORDER BY id");
return Ok(roles);
}
catch { return Ok(Array.Empty<object>()); }
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("staff/schedules")]
public async Task<IActionResult> 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<dynamic>(sql, new { ShopId = shopId });
await using var mConn = _dbFactory.CreateConnection("merchant_service");
var staffList = (await mConn.QueryAsync<dynamic>(
"SELECT ms.id, ms.employee_code, ms.phone, sr.name as role FROM merchant_staff ms JOIN staff_roles sr ON ms.role_id = sr.id")).ToList();
var staffMap = staffList.ToDictionary(s => (Guid)s.id, s => new { code = (string?)s.employee_code, role = (string)s.role, phone = (string?)s.phone });
var result = schedules.Select(s => new {
s.id, s.staff_id, s.shop_id, s.day_of_week, s.start_time, s.end_time,
employee_code = staffMap.TryGetValue((Guid)s.staff_id, out var info) ? info.code : null,
role = info?.role, phone = info?.phone
});
return Ok(result);
}
catch { return Ok(Array.Empty<object>()); }
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("schedules")]
public async Task<IActionResult> 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 });
}
/// <summary>
/// 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.
/// </summary>
[HttpPut("schedules/{scheduleId:guid}")]
public async Task<IActionResult> 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();
}
/// <summary>
/// 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.
/// </summary>
[HttpDelete("schedules/{scheduleId:guid}")]
public async Task<IActionResult> 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();
}
}

View File

@@ -0,0 +1,34 @@
using Npgsql;
namespace WebClientTpos.Server.Infrastructure;
/// <summary>
/// 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.
/// </summary>
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";
}
/// <summary>
/// EN: Create a new NpgsqlConnection for the specified database.
/// VI: Tạo NpgsqlConnection mới cho database chỉ định.
/// </summary>
public NpgsqlConnection CreateConnection(string database) =>
new($"Host={_host};Port={_port};Database={database};Username={_user};Password={_pass}");
}

View File

@@ -0,0 +1,135 @@
using System.IdentityModel.Tokens.Jwt;
using Dapper;
namespace WebClientTpos.Server.Infrastructure;
/// <summary>
/// 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.
/// </summary>
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<Guid>? _cachedShopIds;
public TenantContext(IHttpContextAccessor httpContextAccessor, BffDbConnectionFactory dbFactory)
{
_httpContextAccessor = httpContextAccessor;
_dbFactory = dbFactory;
}
/// <summary>
/// EN: Extract user ID from JWT token in Authorization header.
/// VI: Trích xuất user ID từ JWT token trong header Authorization.
/// </summary>
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;
}
/// <summary>
/// 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).
/// </summary>
public async Task<Guid?> 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<Guid?>(
"SELECT id FROM merchants WHERE user_id = @UserId AND is_deleted = false",
new { UserId = userId });
return _cachedMerchantId;
}
/// <summary>
/// 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).
/// </summary>
public async Task<List<Guid>> GetShopIdsAsync()
{
if (_cachedShopIds != null) return _cachedShopIds;
var merchantId = await GetMerchantIdAsync();
if (merchantId == null)
{
_cachedShopIds = new List<Guid>();
return _cachedShopIds;
}
await using var conn = _dbFactory.CreateConnection("merchant_service");
var ids = await conn.QueryAsync<Guid>(
"SELECT id FROM shops WHERE merchant_id = @MerchantId AND is_deleted = false",
new { MerchantId = merchantId });
_cachedShopIds = ids.ToList();
return _cachedShopIds;
}
/// <summary>
/// EN: Validate that the given shopId belongs to the current merchant.
/// VI: Xác nhận shopId thuộc merchant hiện tại.
/// </summary>
public async Task<bool> OwnsShopAsync(Guid shopId)
{
var shopIds = await GetShopIdsAsync();
return shopIds.Contains(shopId);
}
/// <summary>
/// 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.
/// </summary>
public async Task<List<Guid>?> 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<Guid> { shopId.Value } : myShopIds;
}
}

View File

@@ -0,0 +1,51 @@
namespace WebClientTpos.Server.Models;
// ═══════════════════════════════════════════════════════════════════════════════
// EN: Request/Response DTOs for BFF endpoints — extracted from BffDataController.
// VI: DTOs request/response cho BFF endpoints — trích xuất từ BffDataController.
// ═══════════════════════════════════════════════════════════════════════════════
// ═══ Catalog ═══
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, string? Type, string? Sku, string? ImageUrl);
public record CreateCategoryRequest(Guid ShopId, string Name, string? Description, int DisplayOrder);
// ═══ Staff ═══
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
public record CreateScheduleRequest(Guid ShopId, Guid StaffId, int DayOfWeek, string StartTime, string EndTime);
// ═══ Orders & POS ═══
public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List<PosOrderItemRequest> Items);
public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice);
// ═══ Inventory ═══
public record UpdateInventoryRequest(int Quantity, int ReorderLevel);
// ═══ Shop ═══
public record UpdateShopRequest(string? Name, string? Phone, string? Email, string? Description, string? OpenTime, string? CloseTime, string? OpenDays);
public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays);
// ═══ Campaigns / Promotions ═══
public record CreateCampaignRequest(string Name, string? Description, decimal FaceValue, int TotalVouchers, DateTime StartDate, DateTime EndDate);
// ═══ Membership ═══
public record CreateMemberRequest(string? Gender, string? CountryCode);
public record UpdateMemberRequest(string? Gender, string? Preferences);
// ═══ F&B — Tables ═══
public record CreateTableRequest(Guid ShopId, string TableNumber, int Capacity, string? Zone);
// ═══ Booking — Appointments ═══
public record CreateAppointmentRequest(Guid ShopId, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid? ServiceId, DateTime StartTime, DateTime EndTime, string? Status = null);
// ═══ Booking — Resources ═══
public record CreateResourceRequest(Guid ShopId, string Name, string ResourceType, int Capacity);
// ═══ F&B — Kitchen ═══
public record UpdateTicketStatusRequest(string Status);
// ═══ F&B — Recipes ═══
public record CreateRecipeRequest(Guid ShopId, Guid ProductId, string Name, string? Instructions, int PrepTimeMinutes, List<RecipeIngredientRequest>? Ingredients);
public record RecipeIngredientRequest(string IngredientName, decimal Quantity, string Unit, decimal CostPerUnit);
// ═══ Reports (unused DTO kept for reference) ═══
public record TopProductItem(string ProductName, long TotalSold, decimal TotalRevenue);

View File

@@ -89,6 +89,12 @@ builder.Services.AddHealthChecks();
// VI: Thêm MVC controllers cho BFF data endpoints
builder.Services.AddControllers();
// EN: Register BFF infrastructure services (connection factory, tenant context)
// VI: Đăng ký services hạ tầng BFF (connection factory, tenant context)
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<WebClientTpos.Server.Infrastructure.BffDbConnectionFactory>();
builder.Services.AddScoped<WebClientTpos.Server.Infrastructure.TenantContext>();
var app = builder.Build();
// ═══════════════════════════════════════════════════════════════════════════════