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:
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>()); }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>()); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user