- BFF: Added 10 new endpoints (staff roles/schedules, orders, wallets, devices, promotions, inventory transactions, membership levels) - PosDataService: Added 14 new client methods with DTOs - Rewrote 19 admin pages from hardcoded to real API: Staff: Create, Schedule, Attendance, Payroll Finance: Overview, Revenue, Expenses, Tax Inventory: PurchaseOrders, StockTransfer, SupplierMgmt Product: MenuBuilder, ModifierGroups, PricingRules Customer: Feedback, LoyaltyProgram System: DeviceManagement, NotificationCenter, IntegrationHub
444 lines
20 KiB
C#
444 lines
20 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Npgsql;
|
|
using Dapper;
|
|
|
|
namespace WebClientTpos.Server.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/bff")]
|
|
public class BffDataController : ControllerBase
|
|
{
|
|
// EN: DB host configurable via env var (Docker: "postgres", dev: "localhost")
|
|
// VI: DB host cấu hình qua env var (Docker: "postgres", dev: "localhost")
|
|
private static readonly string _dbHost = Environment.GetEnvironmentVariable("BFF_DB_HOST") ?? "localhost";
|
|
private static readonly string _dbPort = Environment.GetEnvironmentVariable("BFF_DB_PORT") ?? "5432";
|
|
private static readonly string _dbUser = Environment.GetEnvironmentVariable("BFF_DB_USER") ?? "goodgo";
|
|
private static readonly string _dbPass = Environment.GetEnvironmentVariable("BFF_DB_PASS") ?? "goodgo_dev_2024";
|
|
|
|
private static string ConnStr(string db) =>
|
|
$"Host={_dbHost};Port={_dbPort};Database={db};Username={_dbUser};Password={_dbPass}";
|
|
|
|
[HttpGet("shops")]
|
|
public async Task<IActionResult> GetShops()
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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.is_deleted = false
|
|
ORDER BY s.name");
|
|
return Ok(shops);
|
|
}
|
|
|
|
[HttpGet("shops/{shopId:guid}")]
|
|
public async Task<IActionResult> GetShopById(Guid shopId)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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.is_deleted = false",
|
|
new { ShopId = shopId });
|
|
|
|
if (shop == null)
|
|
return NotFound(new { message = "Shop not found" });
|
|
|
|
return Ok(shop);
|
|
}
|
|
|
|
[HttpGet("staff")]
|
|
public async Task<IActionResult> GetStaff()
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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
|
|
ORDER BY ms.joined_at DESC");
|
|
return Ok(staff);
|
|
}
|
|
|
|
[HttpGet("shops/{shopId}/products")]
|
|
public async Task<IActionResult> GetProducts(Guid shopId)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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);
|
|
}
|
|
|
|
[HttpGet("shops/{shopId}/categories")]
|
|
public async Task<IActionResult> GetCategories(Guid shopId)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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);
|
|
}
|
|
|
|
[HttpGet("shops/{shopId}/tables")]
|
|
public async Task<IActionResult> GetTables(Guid shopId)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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);
|
|
}
|
|
|
|
[HttpGet("shops/{shopId}/appointments")]
|
|
public async Task<IActionResult> GetAppointments(Guid shopId)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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);
|
|
}
|
|
|
|
[HttpGet("shops/{shopId}/resources")]
|
|
public async Task<IActionResult> GetResources(Guid shopId)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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);
|
|
}
|
|
|
|
// ═══ ADMIN-LEVEL PRODUCT ENDPOINTS ═══
|
|
|
|
/// <summary>
|
|
/// EN: Get all products across all shops (admin level).
|
|
/// VI: Lấy tất cả sản phẩm trên tất cả cửa hàng (cấp admin).
|
|
/// </summary>
|
|
[HttpGet("products")]
|
|
public async Task<IActionResult> GetAllProducts([FromQuery] Guid? shopId = null)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
|
|
var sql = @"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";
|
|
if (shopId.HasValue)
|
|
sql += " WHERE p.shop_id = @ShopId";
|
|
sql += " ORDER BY p.name";
|
|
var products = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
|
|
return Ok(products);
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Get all categories across all shops (admin level).
|
|
/// VI: Lấy tất cả danh mục trên tất cả cửa hàng (cấp admin).
|
|
/// </summary>
|
|
[HttpGet("categories")]
|
|
public async Task<IActionResult> GetAllCategories([FromQuery] Guid? shopId = null)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
|
|
var sql = @"SELECT id, name, description, display_order, shop_id, parent_id, is_active
|
|
FROM categories WHERE is_active = true";
|
|
if (shopId.HasValue)
|
|
sql += " AND shop_id = @ShopId";
|
|
sql += " ORDER BY display_order, name";
|
|
var categories = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
|
|
return Ok(categories);
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Create a product via BFF (writes directly to catalog DB).
|
|
/// VI: Tạo sản phẩm qua BFF (ghi trực tiếp vào catalog DB).
|
|
/// </summary>
|
|
[HttpPost("products")]
|
|
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest req)
|
|
{
|
|
var id = Guid.NewGuid();
|
|
// EN: Map type string to type_id / VI: Chuyển type string sang type_id
|
|
var typeId = (req.Type ?? "PreparedFood") switch
|
|
{
|
|
"Physical" => 1,
|
|
"Service" => 2,
|
|
"PreparedFood" => 3,
|
|
_ => 3
|
|
};
|
|
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
|
|
await conn.ExecuteAsync(
|
|
@"INSERT INTO products (id, shop_id, name, description, price, type_id, sku, image_url, is_active, created_at)
|
|
VALUES (@Id, @ShopId, @Name, @Description, @Price, @TypeId, @Sku, @ImageUrl, true, NOW())",
|
|
new { Id = id, req.ShopId, req.Name, req.Description, req.Price, TypeId = typeId, req.Sku, req.ImageUrl });
|
|
return CreatedAtAction(nameof(GetAllProducts), new { }, new { id });
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Delete (deactivate) a product.
|
|
/// VI: Xóa (vô hiệu hóa) sản phẩm.
|
|
/// </summary>
|
|
[HttpDelete("products/{productId:guid}")]
|
|
public async Task<IActionResult> DeleteProduct(Guid productId)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
|
|
await conn.ExecuteAsync(
|
|
"UPDATE products SET is_active = false WHERE id = @Id",
|
|
new { Id = productId });
|
|
return NoContent();
|
|
}
|
|
|
|
// ═══ INVENTORY ENDPOINTS ═══
|
|
|
|
/// <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.
|
|
/// </summary>
|
|
[HttpGet("inventory")]
|
|
public async Task<IActionResult> GetInventory([FromQuery] Guid? shopId = null)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("inventory_service"));
|
|
var sql = @"SELECT id, product_id, shop_id, quantity, reorder_level, reserved_quantity, updated_at
|
|
FROM inventory_items";
|
|
if (shopId.HasValue)
|
|
sql += " WHERE shop_id = @ShopId";
|
|
sql += " ORDER BY quantity ASC";
|
|
var items = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
|
|
|
|
// EN: Enrich with product names from catalog_service
|
|
// VI: Bổ sung tên sản phẩm từ catalog_service
|
|
await using var catConn = new NpgsqlConnection(ConnStr("catalog_service"));
|
|
var products = (await catConn.QueryAsync<dynamic>("SELECT id, name FROM products")).ToList();
|
|
var prodMap = products.ToDictionary(p => (Guid)p.id, p => (string)p.name);
|
|
|
|
var result = items.Select(i => new
|
|
{
|
|
i.id, i.product_id, i.shop_id, i.quantity, i.reorder_level, i.reserved_quantity, i.updated_at,
|
|
product_name = prodMap.TryGetValue((Guid)i.product_id, out var name) ? name : "Unknown"
|
|
});
|
|
return Ok(result);
|
|
}
|
|
|
|
// ═══ MEMBERSHIP/CUSTOMER ENDPOINTS ═══
|
|
|
|
/// <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()
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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);
|
|
}
|
|
|
|
// ═══ STAFF CREATE ENDPOINT ═══
|
|
|
|
/// <summary>
|
|
/// EN: Create a staff member.
|
|
/// VI: Tạo nhân viên mới.
|
|
/// </summary>
|
|
[HttpPost("staff")]
|
|
public async Task<IActionResult> CreateStaff([FromBody] CreateStaffRequest req)
|
|
{
|
|
var id = Guid.NewGuid();
|
|
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
|
|
|
|
// EN: Get default role and status IDs / VI: Lấy ID vai trò và trạng thái mặc định
|
|
var roleId = await conn.QueryFirstOrDefaultAsync<int>(
|
|
"SELECT id FROM staff_roles WHERE name = @Role", new { req.Role }) ;
|
|
if (roleId == 0) roleId = 1; // default to first role
|
|
|
|
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, joined_at)
|
|
VALUES (@Id, @MerchantId, @EmployeeCode, @Phone, @Email, @RoleId, @StatusId, NOW())",
|
|
new { Id = id, req.MerchantId, req.EmployeeCode, req.Phone, req.Email, RoleId = roleId, StatusId = statusId });
|
|
return CreatedAtAction(nameof(GetStaff), new { }, new { id });
|
|
}
|
|
|
|
// ═══ STAFF ROLES ═══
|
|
[HttpGet("staff/roles")]
|
|
public async Task<IActionResult> GetStaffRoles()
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
|
|
var roles = await conn.QueryAsync<dynamic>("SELECT id, name FROM staff_roles ORDER BY id");
|
|
return Ok(roles);
|
|
}
|
|
|
|
// ═══ STAFF SCHEDULES ═══
|
|
[HttpGet("staff/schedules")]
|
|
public async Task<IActionResult> GetStaffSchedules([FromQuery] Guid? shopId = null)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("booking_service"));
|
|
var sql = @"SELECT id, staff_id, shop_id, day_of_week, start_time, end_time FROM staff_schedules";
|
|
if (shopId.HasValue) sql += " WHERE shop_id = @ShopId";
|
|
sql += " ORDER BY day_of_week, start_time";
|
|
var schedules = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
|
|
|
|
// EN: Enrich with staff names / VI: Bổ sung tên nhân viên
|
|
await using var mConn = new NpgsqlConnection(ConnStr("merchant_service"));
|
|
var staffList = (await mConn.QueryAsync<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);
|
|
}
|
|
|
|
// ═══ ORDERS SUMMARY ═══
|
|
[HttpGet("orders")]
|
|
public async Task<IActionResult> GetOrders([FromQuery] Guid? shopId = null)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("order_service"));
|
|
var sql = @"SELECT o.id, o.shop_id, o.total_amount, o.status_id, o.created_at,
|
|
os.name as status
|
|
FROM orders o
|
|
JOIN order_statuses os ON o.status_id = os.id";
|
|
if (shopId.HasValue) sql += " WHERE o.shop_id = @ShopId";
|
|
sql += " ORDER BY o.created_at DESC LIMIT 200";
|
|
var orders = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
|
|
return Ok(orders);
|
|
}
|
|
|
|
// ═══ WALLET/FINANCE ═══
|
|
[HttpGet("wallets")]
|
|
public async Task<IActionResult> GetWallets()
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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 ORDER BY w.created_at DESC");
|
|
return Ok(wallets);
|
|
}
|
|
|
|
[HttpGet("wallet/transactions")]
|
|
public async Task<IActionResult> GetWalletTransactions([FromQuery] int limit = 50)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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
|
|
LEFT JOIN wallet_items wi ON wt.reference_id = wi.id
|
|
ORDER BY wt.created_at DESC LIMIT @Limit",
|
|
new { Limit = limit });
|
|
return Ok(txns);
|
|
}
|
|
|
|
// ═══ DEVICES ═══
|
|
[HttpGet("devices")]
|
|
public async Task<IActionResult> GetDevices()
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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);
|
|
}
|
|
|
|
// ═══ PROMOTIONS ═══
|
|
[HttpGet("promotions")]
|
|
public async Task<IActionResult> GetPromotions()
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("promotion_service"));
|
|
var promos = await conn.QueryAsync<dynamic>(
|
|
@"SELECT c.id, c.name, c.description, c.start_date, c.end_date, c.is_active, c.discount_type, c.discount_value,
|
|
(SELECT COUNT(*) FROM vouchers v WHERE v.campaign_id = c.id) as voucher_count,
|
|
(SELECT COUNT(*) FROM redemptions r WHERE r.campaign_id = c.id) as redemption_count
|
|
FROM campaigns c ORDER BY c.created_at DESC");
|
|
return Ok(promos);
|
|
}
|
|
|
|
// ═══ INVENTORY TRANSACTIONS ═══
|
|
[HttpGet("inventory/transactions")]
|
|
public async Task<IActionResult> GetInventoryTransactions([FromQuery] Guid? shopId = null)
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("inventory_service"));
|
|
var sql = @"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";
|
|
if (shopId.HasValue)
|
|
sql += @" JOIN inventory_items ii ON it.inventory_item_id = ii.id WHERE ii.shop_id = @ShopId";
|
|
sql += " ORDER BY it.created_at DESC LIMIT 100";
|
|
var txns = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
|
|
return Ok(txns);
|
|
}
|
|
|
|
// ═══ MEMBERSHIP LEVELS ═══
|
|
[HttpGet("membership/levels")]
|
|
public async Task<IActionResult> GetMembershipLevels()
|
|
{
|
|
await using var conn = new NpgsqlConnection(ConnStr("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);
|
|
}
|
|
|
|
// EN: Request DTOs / VI: DTO yêu cầu
|
|
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, string? Type, string? Sku, string? ImageUrl);
|
|
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
|
|
}
|