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
This commit is contained in:
Ho Ngoc Hai
2026-03-03 15:23:12 +07:00
parent d969f3dda8
commit e74527dc8f
5 changed files with 251 additions and 10 deletions

53
.scratchpad/context.md Normal file
View File

@@ -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ó

40
.scratchpad/plan.md Normal file
View File

@@ -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

View File

@@ -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;

View File

@@ -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<PosOrderItemRequest> 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<CreatePosOrderResponse?> 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<CreatePosOrderResponse>(_jsonOptions);
return null;
}
}

View File

@@ -898,7 +898,97 @@ public class BffDataController : ControllerBase
_ => method
};
// ═══ POS ORDER CREATE (create real order from POS checkout) ═══
/// <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 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<PosOrderItemRequest> Items);
public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice);
}