From cd979970e72f70c826cbee94ce48cc70b947794c Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 5 Mar 2026 08:28:32 +0700 Subject: [PATCH] feat(fnb, tpos): implement table QR code scanning for customer menu and reservation management --- .../Layout/CustomerLayout.razor | 10 + .../Pages/Admin/Shop/ShopPage.razor | 2 +- .../Pages/Admin/Shop/ShopReservations.razor | 262 ++++++++++++++++ .../Pages/Admin/Shop/ShopTables.razor | 108 +++++++ .../Pages/Customer/TableMenu.razor | 280 ++++++++++++++++++ .../Services/PosDataService.cs | 32 +- .../Controllers/FnbController.cs | 10 + .../Application/Queries/GetTablesQuery.cs | 3 +- .../Queries/GetTablesQueryHandler.cs | 3 +- .../Controllers/TablesController.cs | 48 ++- .../TableAggregate/ITableRepository.cs | 6 + .../AggregatesModel/TableAggregate/Table.cs | 15 + .../TableEntityTypeConfiguration.cs | 10 + .../Repositories/TableRepository.cs | 6 + 14 files changed, 790 insertions(+), 5 deletions(-) create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/CustomerLayout.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopReservations.razor create mode 100644 apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Customer/TableMenu.razor diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/CustomerLayout.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/CustomerLayout.razor new file mode 100644 index 00000000..56f09b11 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/CustomerLayout.razor @@ -0,0 +1,10 @@ +@inherits LayoutComponentBase + + + + + + +
+ @Body +
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor index e6624d9a..d495ca51 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor @@ -304,7 +304,7 @@ break; case "reservations": - @RenderStubSection("calendar-check", "#3B82F6", "Đặt bàn", "Quản lý đặt bàn trước — tính năng đang phát triển.") + break; case "happy-hour": diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopReservations.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopReservations.razor new file mode 100644 index 00000000..a07bed11 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopReservations.razor @@ -0,0 +1,262 @@ +@using WebClientTpos.Client.Services +@using WebClientTpos.Client.Pages.Admin.Shop +@inject PosDataService DataService + +
+
+ +
+ @foreach (var st in new[] { ("all", "", "Tất cả"), ("pending", "clock", "Chờ"), ("confirmed", "check-circle", "Xác nhận"), ("seated", "armchair", "Đã ngồi"), ("cancelled", "x-circle", "Đã hủy"), ("no_show", "user-x", "Vắng") }) + { + var count = st.Item1 == "all" ? _reservations.Count : _reservations.Count(r => r.Status == st.Item1); + + } +
+
+ +
+ +@if (_showForm) +{ +
+

Tạo đặt bàn mới

+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ + +
+ @if (_formMessage != null) {
@_formMessage
} +
+
+} + +@if (_loading) +{ +
+ +

Đang tải...

+
+} +else if (!FilteredReservations.Any()) +{ +
+
+ +
+

Chưa có đặt bàn

+

@(_statusFilter == "all" ? "Nhấn 'Tạo đặt bàn' để thêm lịch đặt bàn mới" : "Không có đặt bàn nào ở trạng thái này")

+
+} +else +{ +
+ @foreach (var res in FilteredReservations) + { + var (statusColor, statusBg, statusText, statusIcon) = res.Status switch + { + "pending" => ("#F59E0B", "rgba(245,158,11,0.08)", "Chờ xác nhận", "clock"), + "confirmed" => ("#3B82F6", "rgba(59,130,246,0.08)", "Đã xác nhận", "check-circle"), + "seated" => ("#22C55E", "rgba(34,197,94,0.08)", "Đã ngồi", "armchair"), + "cancelled" => ("#EF4444", "rgba(239,68,68,0.08)", "Đã hủy", "x-circle"), + "no_show" => ("#6B6B6F", "rgba(107,107,111,0.08)", "Vắng mặt", "user-x"), + _ => ("#6B6B6F", "rgba(107,107,111,0.08)", res.Status, "circle") + }; + var tableName = _tables.FirstOrDefault(t => t.Id == res.TableId)?.TableNumber; +
+ @* Status badge *@ +
+ @statusText +
+ + @* Guest info *@ +
+
+ +
+
+
@res.GuestName
+ @if (!string.IsNullOrEmpty(res.Phone)) + { +
+ @res.Phone +
+ } +
+
+ + @* Details *@ +
+ @res.ReservationTime.ToString("HH:mm") + @res.PartySize khách + @if (tableName != null) + { + Bàn @tableName + } +
+ + @if (!string.IsNullOrEmpty(res.Note)) + { +
+ @res.Note +
+ } + + @* Actions *@ + @if (res.Status == "pending" || res.Status == "confirmed") + { +
+ @if (res.Status == "pending") + { + + } + @if (res.Status == "confirmed") + { + + + } + +
+ } +
+ } +
+} + +@code { + [Parameter] public Guid ShopId { get; set; } + + private List _reservations = new(); + private List _tables = new(); + private string _statusFilter = "all"; + private DateTime _selectedDate = DateTime.Today; + private bool _loading = true; + private bool _showForm; + private bool _saving; + + // Form fields + private string _formGuestName = ""; + private string? _formPhone; + private int _formPartySize = 2; + private DateTime _formReservationTime = DateTime.Today.AddHours(12); + private string _formTableId = ""; + private string? _formNote; + private string? _formMessage; + private bool _formSuccess; + + private IEnumerable FilteredReservations => + _statusFilter == "all" ? _reservations : _reservations.Where(r => r.Status == _statusFilter); + + protected override async Task OnInitializedAsync() + { + if (ShopId != Guid.Empty) + { + _tables = await DataService.GetTablesAsync(ShopId); + await LoadReservations(); + } + _loading = false; + } + + private async Task LoadReservations() + { + if (ShopId == Guid.Empty) return; + _reservations = await DataService.GetReservationsAsync(ShopId, _selectedDate.ToString("yyyy-MM-dd")); + } + + private void ToggleForm() + { + _showForm = !_showForm; + if (_showForm) + { + _formGuestName = ""; + _formPhone = null; + _formPartySize = 2; + _formReservationTime = _selectedDate.Date.AddHours(DateTime.Now.Hour + 1); + _formTableId = ""; + _formNote = null; + _formMessage = null; + } + } + + private async Task CreateReservation() + { + _formMessage = null; + if (string.IsNullOrWhiteSpace(_formGuestName)) + { + _formMessage = "Vui lòng nhập tên khách."; _formSuccess = false; return; + } + if (_formPartySize <= 0) + { + _formMessage = "Số khách phải lớn hơn 0."; _formSuccess = false; return; + } + + _saving = true; + try + { + Guid? tableId = Guid.TryParse(_formTableId, out var tid) ? tid : null; + var req = new PosDataService.CreateReservationRequest2( + ShopId, _formGuestName.Trim(), _formPartySize, _formReservationTime, + string.IsNullOrWhiteSpace(_formPhone) ? null : _formPhone.Trim(), + tableId, + string.IsNullOrWhiteSpace(_formNote) ? null : _formNote.Trim()); + var ok = await DataService.CreateReservationAsync(req); + if (ok) + { + _formMessage = "Tạo đặt bàn thành công!"; _formSuccess = true; + _formGuestName = ""; _formPhone = null; _formPartySize = 2; _formTableId = ""; _formNote = null; + await LoadReservations(); + } + else + { + _formMessage = "Không thể tạo đặt bàn. Vui lòng thử lại."; _formSuccess = false; + } + } + catch (Exception ex) { _formMessage = $"Lỗi: {ex.Message}"; _formSuccess = false; } + finally { _saving = false; } + } + + private async Task UpdateStatus(Guid reservationId, string newStatus) + { + try + { + var ok = await DataService.UpdateReservationStatusAsync(reservationId, newStatus); + if (ok) await LoadReservations(); + } + catch (Exception ex) { Console.Error.WriteLine($"Update status failed: {ex.Message}"); } + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor index 738edb73..130578ce 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopTables.razor @@ -1,6 +1,8 @@ @using WebClientTpos.Client.Services @using WebClientTpos.Client.Pages.Admin.Shop @inject PosDataService DataService +@inject IJSRuntime JS +@inject NavigationManager Nav @if (SubSection == "tables") { @@ -60,6 +62,7 @@ var statusText = table.Status switch { "available" => "Trống", "occupied" => "Đang dùng", "reserved" => "Đã đặt", "cleaning" => "Dọn dẹp", _ => table.Status };
+
@@ -252,6 +255,58 @@ else if (SubSection == "zones")
} +@* ═══ QR MODAL ═══ *@ +@if (_showQrModal && _qrTable != null) +{ +
+
+
+

QR Code — Bàn @_qrTable.TableNumber

+ +
+ @if (_qrGenerating) + { +
+ +

Đang tạo mã QR...

+
+ } + else if (!string.IsNullOrEmpty(_qrToken)) + { + var qrUrl = $"{Nav.BaseUri}table/{_qrToken}"; +
+ QR Code +
+
+
URL khách hàng:
+
@qrUrl
+
+
+ + +
+ @if (_qrCopied) + { +
Đã sao chép!
+ } + } + else + { +
+

Bàn chưa có mã QR. Tạo mã QR để khách hàng quét đặt món.

+ +
+ } +
+
+} + @code { [Parameter] public Guid ShopId { get; set; } [Parameter] public string SubSection { get; set; } = "tables"; @@ -260,6 +315,12 @@ else if (SubSection == "zones") private List _tables = new(); // View mode private bool _floorPlanView; + // QR modal state + private bool _showQrModal; + private PosDataService.TableInfo? _qrTable; + private string? _qrToken; + private bool _qrGenerating; + private bool _qrCopied; // Table form state private bool _showTableForm; private Guid? _editingTableId; @@ -430,6 +491,53 @@ else if (SubSection == "zones") } } + // ═══ QR METHODS ═══ + private void ShowQrModal(PosDataService.TableInfo table) + { + _qrTable = table; + _qrToken = table.QrToken; + _qrGenerating = false; + _qrCopied = false; + _showQrModal = true; + } + + private async Task GenerateQr() + { + if (_qrTable == null) return; + _qrGenerating = true; + try + { + var token = await DataService.GenerateTableQrTokenAsync(_qrTable.Id); + if (token != null) + { + _qrToken = token; + _tables = await DataService.GetTablesAsync(ShopId); + } + } + catch (Exception ex) { Console.Error.WriteLine($"QR generation failed: {ex.Message}"); } + finally { _qrGenerating = false; } + } + + private async Task CopyQrUrl(string url) + { + try + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", url); + _qrCopied = true; + StateHasChanged(); + } + catch { } + } + + private async Task PrintQr(string url) + { + try + { + await JS.InvokeVoidAsync("eval", $"var w=window.open('','_blank','width=400,height=500');w.document.write('

Bàn {_qrTable?.TableNumber}

{url}

');w.document.close();w.print();"); + } + catch { } + } + private RenderFragment RenderEmpty(string icon, string color, string title, string desc, string? ctaIcon = null, string? ctaLabel = null, string? ctaHref = null) => __builder => {
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Customer/TableMenu.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Customer/TableMenu.razor new file mode 100644 index 00000000..747203cb --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Customer/TableMenu.razor @@ -0,0 +1,280 @@ +@page "/table/{Token}" +@layout WebClientTpos.Client.Layout.CustomerLayout +@using WebClientTpos.Client.Services +@inject PosDataService DataService +@inject NavigationManager Nav + +@if (_loading) +{ +
+
+
+

Đang tải menu...

+
+
+} +else if (_error != null) +{ +
+
+
+ ! +
+

Không tìm thấy bàn

+

@_error

+
+
+} +else +{ + @* ═══ HEADER ═══ *@ +
+
+
+
+

@_shopName

+

Bàn @_tableNumber

+
+
+ @if (_cart.Any()) + { + + } + +
+
+
+
+ +
+ @* ═══ CATEGORY TABS ═══ *@ + @if (_categories.Any()) + { +
+ + @foreach (var cat in _categories) + { + + } +
+ } + + @* ═══ PRODUCTS ═══ *@ + @if (!FilteredProducts.Any()) + { +
+

🍽️

+

Chưa có sản phẩm nào

+
+ } + else + { +
+ @foreach (var product in FilteredProducts) + { + var inCart = _cart.FirstOrDefault(c => c.ProductId == product.Id); +
+
+
@product.Name
+ @if (!string.IsNullOrEmpty(product.Description)) + { +
@product.Description
+ } +
@FormatVND(product.Price)
+ @if (inCart != null) + { +
+ + @inCart.Qty + +
+ } + else + { + + } +
+
+ } +
+ } +
+ + @* ═══ CART PANEL ═══ *@ + @if (_showCart && _cart.Any()) + { +
+
+
+

Giỏ hàng

+ +
+ @foreach (var item in _cart) + { +
+
+
@item.Name
+
@FormatVND(item.Price) × @item.Qty
+
+
@FormatVND(item.Price * item.Qty)
+
+ } +
+ Tổng cộng + @FormatVND(_cart.Sum(c => c.Price * c.Qty)) +
+ + @if (_orderMessage != null) + { +
@_orderMessage
+ } +
+
+ } + + @* ═══ STAFF CALLED TOAST ═══ *@ + @if (_staffCalled) + { +
+ Đã thông báo nhân viên! +
+ } +} + + + +@code { + [Parameter] public string Token { get; set; } = ""; + + private bool _loading = true; + private string? _error; + private Guid _shopId; + private Guid _tableId; + private string _tableNumber = ""; + private string _shopName = ""; + + private List _products = new(); + private List _categories = new(); + private Guid? _selectedCategory; + + private readonly List _cart = new(); + private bool _showCart; + private bool _ordering; + private string? _orderMessage; + private bool _orderSuccess; + private bool _staffCalled; + + private IEnumerable FilteredProducts => + _selectedCategory == null ? _products : _products.Where(p => p.CategoryId == _selectedCategory); + + protected override async Task OnInitializedAsync() + { + try + { + var tableInfo = await DataService.GetTableByTokenAsync(Token); + if (tableInfo == null) + { + _error = "Mã QR không hợp lệ hoặc đã hết hạn."; + _loading = false; + return; + } + + _tableId = tableInfo.Id; + _shopId = tableInfo.ShopId; + _tableNumber = tableInfo.TableNumber; + + var shop = await DataService.GetShopByIdAsync(_shopId); + _shopName = shop?.Name ?? "Nhà hàng"; + + _products = await DataService.GetProductsAsync(_shopId); + _categories = await DataService.GetCategoriesAsync(_shopId); + } + catch + { + _error = "Không thể tải menu. Vui lòng thử lại."; + } + _loading = false; + } + + private void AddToCart(PosDataService.ProductInfo product) + { + _cart.Add(new CartItem(product.Id, product.Name, product.Price, 1)); + } + + private void UpdateCartQty(Guid productId, int delta) + { + var item = _cart.FirstOrDefault(c => c.ProductId == productId); + if (item == null) return; + item.Qty += delta; + if (item.Qty <= 0) _cart.Remove(item); + } + + private async Task PlaceOrder() + { + _ordering = true; + _orderMessage = null; + try + { + var items = _cart.Select(c => new PosDataService.PosOrderItemRequest( + c.ProductId, c.Name, c.Qty, c.Price, "PreparedFood")).ToList(); + var req = new PosDataService.CreatePosOrderRequest( + _shopId, null, items, null, null, null, _tableId); + var result = await DataService.CreatePosOrderAsync(req); + if (result != null) + { + _orderMessage = "Đặt món thành công! Món sẽ được phục vụ sớm nhất."; + _orderSuccess = true; + _cart.Clear(); + _showCart = false; + } + else + { + _orderMessage = "Không thể đặt món. Vui lòng thử lại."; + _orderSuccess = false; + } + } + catch + { + _orderMessage = "Lỗi khi đặt món. Vui lòng thử lại."; + _orderSuccess = false; + } + _ordering = false; + } + + private async Task CallStaff() + { + _staffCalled = true; + StateHasChanged(); + await Task.Delay(3000); + _staffCalled = false; + StateHasChanged(); + } + + private static string FormatVND(decimal amount) => $"{amount:N0}đ"; + + private class CartItem + { + public Guid ProductId { get; set; } + public string Name { get; set; } + public decimal Price { get; set; } + public int Qty { get; set; } + public CartItem(Guid productId, string name, decimal price, int qty) + { ProductId = productId; Name = name; Price = price; Qty = qty; } + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index b8cd6a15..c00c9b7a 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -140,7 +140,7 @@ public class PosDataService public string? Category => CategoryName; } public record CategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder, string? ImageUrl = null); - public record TableInfo(Guid Id, string TableNumber, int Capacity, string? Zone, string Status, Guid? SessionId, int? GuestCount, DateTime? StartedAt, int? PositionX = null, int? PositionY = null); + public record TableInfo(Guid Id, string TableNumber, int Capacity, string? Zone, string Status, Guid? SessionId, int? GuestCount, DateTime? StartedAt, int? PositionX = null, int? PositionY = null, string? QrToken = null); public record AppointmentInfo(Guid Id, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid ServiceId, DateTime StartTime, DateTime EndTime, string Status, string? ResourceName); public record ShopAssignmentInfo(Guid ShopId, string? ShopRole, Guid? BranchId); public record StaffInfo(Guid Id, Guid? UserId, string? EmployeeCode, string? Phone, string? Email, DateTime? JoinedAt, DateTime? TerminatedAt, string? Role, string? Status, string? ShopName, @@ -1009,6 +1009,36 @@ public class PosDataService return resp.IsSuccessStatusCode; } + // ═══ TABLE QR ═══ + + public async Task GenerateTableQrTokenAsync(Guid tableId) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync($"api/bff/tables/{tableId}/generate-qr", new { }, _writeOptions); + if (resp.IsSuccessStatusCode) + { + var json = await resp.Content.ReadFromJsonAsync(_jsonOptions); + if (json.TryGetProperty("data", out var data) && data.TryGetProperty("qrToken", out var token)) + return token.GetString(); + } + return null; + } + + public record TableByTokenInfo(Guid Id, Guid ShopId, string TableNumber, int Capacity, string? Zone); + + public async Task GetTableByTokenAsync(string token) + { + AttachToken(); + var resp = await _http.GetAsync($"api/bff/tables/by-token/{token}"); + if (resp.IsSuccessStatusCode) + { + var json = await resp.Content.ReadFromJsonAsync(_jsonOptions); + if (json.TryGetProperty("data", out var data)) + return System.Text.Json.JsonSerializer.Deserialize(data.GetRawText(), _jsonOptions); + } + return null; + } + // ═══ RESERVATIONS ═══ public record ReservationInfo(Guid Id, Guid ShopId, Guid? TableId, string GuestName, string? Phone, diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs index d7843e25..9569c860 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/FnbController.cs @@ -119,6 +119,16 @@ public class FnbController : ControllerBase public Task CreateReservation([FromBody] JsonElement body) => _fnb.PostAsJsonAsync("/api/v1/reservations", body).ProxyAsync(); + // ═══ TABLE QR ═══ + + [HttpPost("tables/{tableId:guid}/generate-qr")] + public Task GenerateTableQr(Guid tableId) => + _fnb.PostAsJsonAsync($"/api/v1/tables/{tableId}/generate-qr", new { }).ProxyAsync(); + + [HttpGet("tables/by-token/{token}")] + public Task GetTableByToken(string token) => + _fnb.GetAsync($"/api/v1/tables/by-token/{token}").ProxyAsync(); + [HttpPatch("reservations/{id:guid}/status")] public Task UpdateReservationStatus(Guid id, [FromBody] JsonElement body) { diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQuery.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQuery.cs index aafbb805..bea2482e 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQuery.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQuery.cs @@ -23,5 +23,6 @@ public record TableDto( string? Zone, string Status, int? PositionX = null, - int? PositionY = null + int? PositionY = null, + string? QrToken = null ); diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQueryHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQueryHandler.cs index d8c7fc39..7111c106 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQueryHandler.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/Queries/GetTablesQueryHandler.cs @@ -35,7 +35,8 @@ public class GetTablesQueryHandler : IRequestHandler _logger; + private readonly ITableRepository _tableRepository; - public TablesController(IMediator mediator, ILogger logger) + public TablesController(IMediator mediator, ILogger logger, ITableRepository tableRepository) { _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _tableRepository = tableRepository ?? throw new ArgumentNullException(nameof(tableRepository)); } /// @@ -102,6 +105,49 @@ public class TablesController : ControllerBase var result = await _mediator.Send(command, ct); return Ok(new ApiResponse { Success = true, Data = result }); } + /// + /// EN: Generate QR token for a table. + /// VI: Tạo QR token cho bàn. + /// + [HttpPost("{id}/generate-qr")] + [ProducesResponseType(typeof(ApiResponse), 200)] + [ProducesResponseType(404)] + public async Task>> GenerateQrToken( + Guid id, + CancellationToken ct = default) + { + var table = await _tableRepository.GetByIdAsync(id, ct); + if (table is null) + return NotFound(new ApiResponse { Success = false, Error = "Table not found" }); + + var token = table.GenerateQrToken(); + _tableRepository.Update(table); + await _tableRepository.UnitOfWork.SaveEntitiesAsync(ct); + + return Ok(new ApiResponse { Success = true, Data = new { qrToken = token } }); + } + + /// + /// EN: Get table by QR token (public, no auth). + /// VI: Lấy bàn theo QR token (public, không cần auth). + /// + [HttpGet("by-token/{token}")] + [ProducesResponseType(typeof(ApiResponse), 200)] + [ProducesResponseType(404)] + public async Task>> GetByQrToken( + string token, + CancellationToken ct = default) + { + var table = await _tableRepository.GetByQrTokenAsync(token, ct); + if (table is null) + return NotFound(new ApiResponse { Success = false, Error = "Table not found" }); + + return Ok(new ApiResponse + { + Success = true, + Data = new { table.Id, table.ShopId, table.TableNumber, table.Capacity, table.Zone } + }); + } } /// diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/ITableRepository.cs b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/ITableRepository.cs index bc847148..7b919982 100644 --- a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/ITableRepository.cs +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/ITableRepository.cs @@ -40,4 +40,10 @@ public interface ITableRepository : IRepository /// VI: Lấy bàn theo shop ID và số bàn. /// Task GetByNumberAsync(Guid shopId, string tableNumber, CancellationToken cancellationToken = default); + + /// + /// EN: Get table by QR token. + /// VI: Lấy bàn theo QR token. + /// + Task GetByQrTokenAsync(string qrToken, CancellationToken cancellationToken = default); } diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/Table.cs b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/Table.cs index 0c421772..d2cb488e 100644 --- a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/Table.cs +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/TableAggregate/Table.cs @@ -19,6 +19,7 @@ public class Table : Entity, IAggregateRoot private TableStatus _status = null!; private int? _positionX; private int? _positionY; + private string? _qrToken; private DateTime _createdAt; private DateTime? _updatedAt; @@ -30,6 +31,7 @@ public class Table : Entity, IAggregateRoot public int StatusId { get; private set; } public int? PositionX => _positionX; public int? PositionY => _positionY; + public string? QrToken => _qrToken; public DateTime CreatedAt => _createdAt; public DateTime? UpdatedAt => _updatedAt; @@ -86,4 +88,17 @@ public class Table : Entity, IAggregateRoot _positionY = y; _updatedAt = DateTime.UtcNow; } + + public string GenerateQrToken() + { + _qrToken = Guid.NewGuid().ToString("N")[..16]; + _updatedAt = DateTime.UtcNow; + return _qrToken; + } + + public void ClearQrToken() + { + _qrToken = null; + _updatedAt = DateTime.UtcNow; + } } diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/TableEntityTypeConfiguration.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/TableEntityTypeConfiguration.cs index af8f62a7..fab5d0f1 100644 --- a/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/TableEntityTypeConfiguration.cs +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/EntityConfigurations/TableEntityTypeConfiguration.cs @@ -53,6 +53,11 @@ public class TableEntityTypeConfiguration : IEntityTypeConfiguration
.HasField("_positionY") .HasColumnName("position_y"); + builder.Property(t => t.QrToken) + .HasField("_qrToken") + .HasColumnName("qr_token") + .HasMaxLength(64); + builder.Property(t => t.CreatedAt) .HasField("_createdAt") .HasColumnName("created_at") @@ -70,6 +75,11 @@ public class TableEntityTypeConfiguration : IEntityTypeConfiguration
.HasDatabaseName("ix_tables_shop_table_number") .IsUnique(); + builder.HasIndex(t => t.QrToken) + .HasDatabaseName("ix_tables_qr_token") + .IsUnique() + .HasFilter("qr_token IS NOT NULL"); + // EN: Ignore the Status navigation (enumeration loaded via StatusId) // VI: Bỏ qua navigation Status (enumeration được load qua StatusId) builder.Ignore(t => t.Status); diff --git a/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/TableRepository.cs b/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/TableRepository.cs index efcdaa72..6acea478 100644 --- a/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/TableRepository.cs +++ b/services/fnb-engine-net/src/FnbEngine.Infrastructure/Repositories/TableRepository.cs @@ -51,4 +51,10 @@ public class TableRepository : ITableRepository return await _context.Tables .FirstOrDefaultAsync(t => t.ShopId == shopId && t.TableNumber == tableNumber, cancellationToken); } + + public async Task GetByQrTokenAsync(string qrToken, CancellationToken cancellationToken = default) + { + return await _context.Tables + .FirstOrDefaultAsync(t => t.QrToken == qrToken, cancellationToken); + } }