From e74527dc8fd15e929b5a1e023e8c38c39e5b05ec Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 3 Mar 2026 15:23:12 +0700 Subject: [PATCH] feat(web-client-tpos): implement POS order creation via BFF API - Add POST /api/bff/pos/orders endpoint (Dapper, order+items+payment_method) - Add CreatePosOrderAsync to PosDataService with camelCase JSON serialization - Wire CafeDesktop ConfirmPayment to API (async, ProductId tracking, fallback) - Add TryRestoreSessionAsync to POS page init for JWT auth - Replace Forbid() with Unauthorized() + diagnostic logging --- .scratchpad/context.md | 53 +++++++++++ .scratchpad/plan.md | 40 +++++++++ .../Pages/Pos/Cafe/CafeDesktop.razor | 54 ++++++++--- .../Services/PosDataService.cs | 24 +++++ .../Controllers/BffDataController.cs | 90 +++++++++++++++++++ 5 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 .scratchpad/context.md create mode 100644 .scratchpad/plan.md diff --git a/.scratchpad/context.md b/.scratchpad/context.md new file mode 100644 index 00000000..aeeff7a5 --- /dev/null +++ b/.scratchpad/context.md @@ -0,0 +1,53 @@ +# Scratchpad Context — GoodGo Platform +> Updated: 2026-03-03 14:22 + +## Trạng thái hệ thống + +### Infrastructure +- **Docker**: 29 containers running (healthy), Traefik reverse proxy +- **DB**: PostgreSQL (postgres-local), separate DB per service +- **Cache**: Redis (redis-local) +- **Queue**: RabbitMQ (rabbitmq-local) +- **Storage**: MinIO (minio-local) + +### Services (26 .NET microservices) +| Service | Status | Notes | +|---------|--------|-------| +| iam-service-net | ✅ healthy | Auth, Users, Roles | +| merchant-service-net | ✅ healthy | Merchants, Shops, Staff | +| catalog-service-net | ✅ healthy | Products, Categories | +| order-service-net | ✅ healthy | Orders, Order Items | +| booking-service-net | ✅ healthy | Appointments, Resources | +| fnb-engine-net | ✅ healthy | Tables, Sessions | +| inventory-service-net | ✅ healthy | Stock management | +| promotion-service-net | ✅ healthy | Campaigns, Vouchers | +| membership-service-net | ✅ healthy | Members, Levels | +| wallet-service-net | ✅ healthy | Wallets, Transactions | +| chat-service-net | ✅ healthy | Chat, SignalR | +| social-service-net | ✅ healthy | Social features | +| storage-service-net | ✅ healthy | File storage | +| mining-service-net | ✅ healthy | Data mining | +| mission-service-net | ✅ healthy | Gamification | +| ads-*-service-net (5) | ✅ healthy | Ads ecosystem | +| mkt-*-service-net (4) | ✅ healthy | Marketing integrations | + +### Apps +| App | Type | Port | Status | +|-----|------|------|--------| +| web-client-tpos-net | Blazor WASM POS | 3001 | ✅ Running | +| web-client-base-net | Blazor Admin | — | Not deployed locally | +| app-client-base-net | .NET MAUI Mobile | — | Not deployed | +| app-client-base-swift | Swift iOS | — | Not deployed | +| web-docs | Documentation site | — | Not deployed | + +## Recent Changes (từ Git log) +1. `d969f3d` — **feat**: Replace hardcoded POS data with API-driven endpoints +2. `fe6e14c` — **feat**: Unify POS with inline payment and tabs (path fix) +3. `15404a8` — **feat**: Unify POS page with tabs and inline payment +4. `617a7ca` — **fix**: Resolve 500 error on GET /api/v1/users endpoint +5. `ad6fe03` — **feat**: Add users management page and enhance roles CRUD +6. `f3bfcc8` — **fix**: Roles API response parsing and property mapping +7. `f353f88` — **feat**: Redesign Home/NotFound pages, localize Dashboard + +## Blockers +- Chưa có diff --git a/.scratchpad/plan.md b/.scratchpad/plan.md new file mode 100644 index 00000000..ab86c02f --- /dev/null +++ b/.scratchpad/plan.md @@ -0,0 +1,40 @@ +# Task Plan — GoodGo Platform +> Updated: 2026-03-03 14:22 + +## ✅ Completed Tasks +1. ~~Admin Users & Roles — CRUD, phân quyền~~ +2. ~~POS Unified page — 3 tabs (Sale, History, Dashboard)~~ +3. ~~POS Inline Payment Flow~~ +4. ~~Replace hardcoded POS data with API endpoints~~ + +## Pending Tasks (Ưu tiên cao → thấp) + +### 🔴 P1 — POS Core (Hoàn thiện) +- [ ] **Order API Create**: `ConfirmPayment` gọi Order Service API tạo order thật (hiện chỉ lưu in-memory session) +- [ ] **Order Items**: Lưu order_items (product_id, qty, unit_price) khi confirm +- [ ] **Payment Method column**: Add `payment_method` column vào orders table nếu chưa có +- [ ] **Print receipt**: Implement in hóa đơn (hoặc generate PDF) + +### 🟡 P2 — Dashboard Real Data +- [ ] **Hourly chart**: Verify `order_items` + `payment_method` tables exist +- [ ] **Popular items**: Test with real order data +- [ ] **History search/filter**: Implement date range filter (today/7 days/30 days) + +### 🟡 P3 — Admin Panel (web-client-tpos-net) +- [ ] **Products CRUD**: Thêm/sửa/xóa products từ Admin +- [ ] **Inventory management**: Stock alerts, reorder +- [ ] **Staff management**: Assign staff to shops +- [ ] **Promotions**: Campaign management UI + +### 🟢 P4 — Frontend Apps +- [ ] **web-client-base-net**: Deploy locally, kiểm tra +- [ ] **app-client-base-swift**: iOS app review +- [ ] **web-docs**: Documentation site + +### 🟢 P5 — Infrastructure +- [ ] **Observability**: Prometheus + Grafana +- [ ] **CI/CD**: GitHub Actions +- [ ] **Testing**: Unit + Integration tests coverage + +## Next Task +> **P1: Order API Create** — Implement `POST /api/bff/orders` endpoint + call from POS ConfirmPayment diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor index dc186171..63042313 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Cafe/CafeDesktop.razor @@ -8,6 +8,7 @@ @layout PosLayout @inherits PosBase @using WebClientTpos.Client.Services +@inject AuthService AuthService @inject PosDataService DataService @* ═══════════════ MAIN CONTENT AREA ═══════════════ *@ @@ -463,6 +464,10 @@ protected override async Task OnInitializedAsync() { + // EN: Restore auth session from localStorage so API calls have JWT token + // VI: Khôi phục session auth từ localStorage để API calls có JWT token + await AuthService.TryRestoreSessionAsync(); + try { var productsTask = DataService.GetProductsAsync(ShopId); @@ -472,7 +477,7 @@ var apiProducts = await productsTask; var apiCategories = await categoriesTask; - _products = apiProducts.Select(p => new Product(p.Name, p.Price, p.Category ?? "Khác")).ToList(); + _products = apiProducts.Select(p => new Product(p.Id, p.Name, p.Price, p.Category ?? "Khác")).ToList(); var catNames = apiCategories.Select(c => c.Name).ToList(); if (catNames.Count > 0) @@ -490,9 +495,9 @@ private void AddToCart(Product product) { if (_paymentStep != PayStep.None) return; - var existing = _cartItems.FirstOrDefault(i => i.Name == product.Name); + var existing = _cartItems.FirstOrDefault(i => i.ProductId == product.Id); if (existing != null) existing.Qty++; - else _cartItems.Add(new CartItem(product.Name, product.Price)); + else _cartItems.Add(new CartItem(product.Id, product.Name, product.Price)); } private void ChangeQty(CartItem item, int delta) @@ -567,14 +572,39 @@ _receivedAmount = val; } - private void ConfirmPayment() + private bool _paymentProcessing; + + private async Task ConfirmPayment() { + if (_paymentProcessing) return; + _paymentProcessing = true; + StateHasChanged(); + _lastOrderTotal = CartTotal; - _lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; + var methodLabel = _selectedMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" }; + + // EN: Call API to create real order in DB + // VI: Gọi API tạo đơn hàng thật trong DB + try + { + var orderReq = new PosDataService.CreatePosOrderRequest( + ShopId, + _selectedMethod, + _cartItems.Select(i => new PosDataService.PosOrderItemRequest( + i.ProductId, i.Name, i.Qty, i.Price)).ToList()); + + var result = await DataService.CreatePosOrderAsync(orderReq); + _lastTransactionId = result?.TransactionId ?? $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; + } + catch + { + // EN: Fallback — generate local ID if API fails + // VI: Fallback — tạo ID local nếu API lỗi + _lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}"; + } // EN: Save to session history (in-memory for this POS session) // VI: Lưu vào lịch sử phiên (in-memory cho phiên POS hiện tại) - var methodLabel = _selectedMethod switch { "cash" => "Tiền mặt", "card" => "Thẻ", "qr" => "QR Code", _ => "Chuyển khoản" }; _sessionOrders.Insert(0, new SessionOrder( _lastTransactionId, string.Join(", ", _cartItems.Select(i => $"{i.Name} x{i.Qty}")), @@ -584,11 +614,14 @@ "Hoàn thành" )); - // EN: Invalidate dashboard cache so next view refreshes - // VI: Xóa cache dashboard để refresh khi xem lại + // EN: Invalidate caches so next view refreshes + // VI: Xóa cache để refresh khi xem lại _dashLoaded = false; + _historyLoaded = false; + _paymentProcessing = false; _paymentStep = PayStep.Success; + StateHasChanged(); } private void ResetAfterPayment() @@ -691,9 +724,10 @@ } // ═══════════════ RECORDS ═══════════════ - private record Product(string Name, decimal Price, string Category); - private class CartItem(string name, decimal price) + private record Product(Guid Id, string Name, decimal Price, string Category); + private class CartItem(Guid productId, string name, decimal price) { + public Guid ProductId { get; set; } = productId; public string Name { get; set; } = name; public decimal Price { get; set; } = price; public int Qty { get; set; } = 1; 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 c89337ed..c369d18d 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 @@ -242,4 +242,28 @@ public class PosDataService $"api/bff/pos/dashboard?shopId={shopId}", _jsonOptions) ?? new(0, 0, 0, 0, new(), new(), new(), new()); } + + // ═══ POS ORDER CREATE ═══ + + // EN: POS order creation DTOs + // VI: DTOs cho tạo đơn POS + public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List Items); + public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice); + public record CreatePosOrderResponse(Guid OrderId, string TransactionId, decimal TotalAmount, string Status); + + public async Task CreatePosOrderAsync(CreatePosOrderRequest req) + { + AttachToken(); + // EN: Use camelCase for POST body (ASP.NET model binding default) + // VI: Dùng camelCase cho POST body (ASP.NET model binding mặc định) + var postOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + var resp = await _http.PostAsJsonAsync("api/bff/pos/orders", req, postOptions); + if (resp.IsSuccessStatusCode) + return await resp.Content.ReadFromJsonAsync(_jsonOptions); + return null; + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs index f454896f..1c1b53cf 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs @@ -898,7 +898,97 @@ public class BffDataController : ControllerBase _ => method }; + // ═══ POS ORDER CREATE (create real order from POS checkout) ═══ + + /// + /// 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. + /// + [HttpPost("pos/orders")] + public async Task CreatePosOrder([FromBody] CreatePosOrderRequest req) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) + { + var userId = GetUserIdFromToken(); + Console.Error.WriteLine($"[BFF] CreatePosOrder: merchantId null. userId={userId}, hasAuth={Request.Headers.ContainsKey("Authorization")}"); + return Unauthorized(new { message = "Merchant not found", userId }); + } + + // EN: Verify shop ownership / VI: Xác nhận quyền sở hữu shop + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + 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 = new NpgsqlConnection(ConnStr("order_service")); + await conn.OpenAsync(); + await using var tx = await conn.BeginTransactionAsync(); + + // EN: Step 1 — Insert order (status_id=5 = Completed for POS instant payment) + // VI: Bước 1 — Insert order (status_id=5 = Hoàn thành cho thanh toán POS tức thì) + 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); + + // EN: Step 2 — Insert order items + // VI: Bước 2 — Insert các mục đơn hàng + 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 }); + } + } + // 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); + public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List Items); + public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice); }