From 14d6c4012c85610966f7096167af0f96a277b1c2 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 3 Mar 2026 21:22:25 +0700 Subject: [PATCH] =?UTF-8?q?feat(web-client-tpos):=20Phase=20A=20=E2=80=94?= =?UTF-8?q?=20categories=20CRUD,=20order=20management,=20shop=20update,=20?= =?UTF-8?q?reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BFF Endpoints (6 new): - POST/PUT/DELETE categories — full CRUD with shop ownership validation - GET orders/{id} — order detail with items - PUT orders/{id}/cancel — cancel non-completed orders (status=6) - PUT shops/{id} — update name, phone, email, hours - GET reports/revenue — daily/weekly/monthly revenue aggregation PosDataService (8 new methods): - CreateCategory, UpdateCategory, DeleteCategory - GetOrderDetail, CancelOrder - UpdateShop - GetRevenueReport ShopPage UI (222 lines): - Menu tab: categories table with add/edit/delete - Finance tab: expandable order rows with items + cancel button - Overview tab: shop info edit form - Reports tab: period selector (Ngày/Tuần/Tháng) + revenue table --- .scratchpad/context.md | 38 ++- .scratchpad/plan.md | 52 ++-- .../Pages/Admin/Shop/ShopPage.razor | 222 +++++++++++++++++- .../Services/PosDataService.cs | 78 ++++++ .../Controllers/BffDataController.cs | 187 +++++++++++++++ 5 files changed, 535 insertions(+), 42 deletions(-) diff --git a/.scratchpad/context.md b/.scratchpad/context.md index 4bf9161b..b446f7a5 100644 --- a/.scratchpad/context.md +++ b/.scratchpad/context.md @@ -1,18 +1,30 @@ -# Context — GoodGo Platform -> Updated: 2026-03-03 20:45 +# Context — GoodGo POS Platform +> Updated: 2026-03-03 21:01 ## Current Status -- **P1 + P2 + P3**: ✅ Hoàn thành +- **Phases Completed**: P1 (Order+Receipt), P2 (Dashboard), P3 (Products+Staff+Inventory CRUD) - **Branch**: master -- **Last Commits**: `8cba902` (Products CRUD) → `15b17f5` (Staff CRUD + Inventory) +- **Last Commit**: `aab80fd` (CreateStaff INSERT fix) +- **Container**: web-client-tpos-net-local healthy on port 3001 -## Recent Changes -- Products: Full CRUD (Create/Read/Update/Delete) in ShopPage Admin -- Staff: Full CRUD (Create/Read/Update/Delete=soft) in ShopPage Admin -- Inventory: Update quantity/reorder level via BFF endpoint -- Bug fixes: CreateStaff auth, created_at NOT NULL +## Active Work +- **Phase A**: Categories CRUD → Order Management → Shop Update → Reports +- **Phase B**: Promotions CRUD → Apply discount POS → Customer CRUD +- **Phase C**: Table CRUD → KDS → Recipes +- **Phase D**: Appointments CRUD → Service Packages → Treatments +- **Phase E**: Shifts → RBAC → Shop Settings -## Tech Stack -- Backend: BFF Controller (Dapper + Npgsql → PostgreSQL) -- Frontend: Blazor WASM (ShopPage.razor + PosDataService.cs) -- Deploy: Docker Compose (deployments/local) +## Key Files +- BFF: `apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs` +- Service: `apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs` +- Admin UI: `apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor` +- Deploy: `deployments/local/docker-compose.yml` → `docker compose build --no-cache web-client-tpos-net` + +## DB Schema Reference +- `merchant_service`: merchants, shops, merchant_staff, shop_tables, shop_resources +- `catalog_service`: products, categories +- `order_service`: orders, order_items +- `inventory_service`: inventory_items, inventory_transactions +- `membership_service`: members, level_definitions +- `wallet_service`: wallets, wallet_transactions +- `promotion_service`: promotions diff --git a/.scratchpad/plan.md b/.scratchpad/plan.md index abb963e6..7a862cea 100644 --- a/.scratchpad/plan.md +++ b/.scratchpad/plan.md @@ -1,34 +1,30 @@ -# Task Plan — GoodGo Platform -> Updated: 2026-03-03 20:45 +# Plan — POS Feature Implementation +> Updated: 2026-03-03 21:01 -## ✅ 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~~ -5. ~~P1: Order API Create — POST /api/bff/pos/orders~~ -6. ~~P1: Print Receipt — Thermal 80mm popup~~ -7. ~~P2: Dashboard Real Data — hourly/popular/payment~~ -8. ~~P2: History Date Filter — today/7d/30d~~ -9. ~~P3: Products CRUD — Create/Read/Update/Delete~~ -10. ~~P3: Staff CRUD — Create/Read/Update/Delete (soft-delete)~~ -11. ~~P3: Inventory Update — PUT quantity/reorder level~~ +## Strategy +- Use Claude CLI subagent for heavy BFF + service code +- Parallel edits where possible +- Commit after each phase +- Deploy + verify after Phase A -## Pending Tasks (Ưu tiên cao → thấp) +## Current: Phase A — POS Core +### A1: Categories CRUD +- BFF: POST/PUT/DELETE categories (existing GET works) +- PosDataService: CreateCategory, UpdateCategory, DeleteCategory +- ShopPage: Add/Edit/Delete buttons on category section (or in menu tab) -### 🟡 P3 — Admin Panel (còn lại) -- [ ] **Promotions**: Campaign management UI -- [ ] **Notifications**: Replace hardcoded bell data +### A2: Order Management +- BFF: GET order/{id} details, PUT order/{id}/cancel +- PosDataService: GetOrderDetailAsync, CancelOrderAsync +- ShopPage finance tab: expand order rows → view items, cancel button -### 🟢 P4 — Frontend Apps -- [x] **web-client-tpos-net**: Deployed locally (port 3001) ✅ -- [ ] **app-client-base-swift**: iOS app review -- [ ] **web-docs**: Documentation site +### A3: Shop Update +- BFF: PUT shop/{id} (name, address, hours, phone) +- PosDataService: UpdateShopAsync +- ShopPage overview: edit shop info form -### 🟢 P5 — Infrastructure -- [ ] **Observability**: Prometheus + Grafana -- [ ] **CI/CD**: GitHub Actions -- [ ] **Testing**: Unit + Integration tests coverage +### A4: Basic Reports +- BFF: GET reports/revenue?period=day|week|month +- ShopPage reports tab: revenue table with totals -## Next Task -> **Deploy & verify** web-client-tpos-net then continue with Promotions or P4 +## Next: Phase B → C → D → E (see task.md) 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 d7cce1b8..5d63f83a 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 @@ -151,6 +151,40 @@ { @RenderEmpty("layout-dashboard", "#3B82F6", "Chào mừng đến cửa hàng!", "Bắt đầu bán hàng qua POS để xem dữ liệu tại đây", "monitor", "Mở POS", $"/pos/{ShopId}/{_posVertical}") } + @* Shop Info Edit *@ +
+
+

Thông tin cửa hàng

+ @if (!_editingShop) + { + + } +
+ @if (_editingShop) + { +
+
+
+
+
+
+
+
+
+
+ + +
+ @if (_shopEditMessage != null) {
@_shopEditMessage
} +
+ } + else + { +
@(_shopName ?? "—") — Nhấn "Chỉnh sửa" để cập nhật thông tin.
+ } +
break; // ═══ MENU / PRODUCTS ═══ @@ -227,6 +261,54 @@ } } + @* Categories Management *@ +
+
+

Danh mục (@_categories.Count)

+ +
+ @if (_showCategoryForm) + { +
+

@(_editingCategoryId.HasValue ? "Chỉnh sửa danh mục" : "Thêm danh mục mới")

+
+
+
+
+ + +
+
+ @if (_categoryFormMessage != null) {
@_categoryFormMessage
} +
+ } +
+ + + + + + @foreach (var c in _categories) + { + + + + + + + } + +
TÊNMÔ TẢTHỨ TỰHÀNH ĐỘNG
@c.Name@c.Description@c.DisplayOrder +
+ + +
+
+ @if (!_categories.Any()) {
Chưa có danh mục. Nhấn "Thêm danh mục" để tạo mới.
} +
+
break; // ═══ INVENTORY ═══ @@ -321,15 +403,51 @@ Số tiền Trạng thái Ngày + @foreach (var o in _orders.Take(20)) { - + var isOrderExpanded = _selectedOrderId == o.Id; + @o.Id.ToString()[..8] @FormatVND(o.TotalAmount) @(o.Status ?? "—") @o.CreatedAt.ToString("dd/MM HH:mm") + + @if (isOrderExpanded && _orderDetail != null) + { + +
+
+
@(_orderDetail.Order?.PaymentMethod ?? "—")
+
@(_orderDetail.Order?.Notes ?? "—")
+
@(_orderDetail.Order?.CreatedAt.ToString("dd/MM/yyyy HH:mm") ?? "—")
+
+ @if (_orderDetail.Items?.Any() == true) + { + + + + @foreach (var item in _orderDetail.Items) + { + + + + + + + } +
Sản phẩmSLĐơn giáThành tiền
@(item.ProductName ?? "—")@item.Quantity@FormatVND(item.UnitPrice)@FormatVND(item.Subtotal)
+ } +
+ +
+
+ + } } @@ -733,6 +851,47 @@
@FormatVND(_reportOrders.Any() ? _reportOrders.Average(o => o.TotalAmount) : 0)Giá trị TB / đơn
@_reportProducts.CountSản phẩm
+ @* Revenue Report *@ +
+
+

Doanh thu theo kỳ

+
+ @foreach (var (label, val) in new[] { ("Ngày", "daily"), ("Tuần", "weekly"), ("Tháng", "monthly") }) + { + + } +
+
+
+ @if (_revenueReport.Any()) + { + + + + + + @foreach (var r in _revenueReport) + { + + + + + + } + +
KỲĐƠN HÀNGDOANH THU
@r.Period.ToString("dd/MM/yyyy")@r.OrderCount@FormatVND(r.Revenue)
+ } + else + { +
Nhấn Ngày / Tuần / Tháng để tải dữ liệu doanh thu.
+ } +
+
@if (_reportProducts.Any()) {
@@ -1561,6 +1720,31 @@ private string _customerSearch = ""; // Finance date range filter state private string _financePeriod = "all"; // 7d, 30d, all + // Category form state + private bool _showCategoryForm; + private Guid? _editingCategoryId; + private string _newCategoryName = ""; + private string _newCategoryDesc = ""; + private int _newCategoryOrder; + private string? _categoryFormMessage; + private bool _categoryFormSuccess; + private List _categories = new(); + // Order detail state + private Guid? _selectedOrderId; + private PosDataService.OrderDetailResponse? _orderDetail; + // Shop edit state + private bool _editingShop; + private string _shopEditName = ""; + private string _shopEditPhone = ""; + private string _shopEditEmail = ""; + private string _shopEditDesc = ""; + private string _shopEditOpenTime = ""; + private string _shopEditCloseTime = ""; + private string? _shopEditMessage; + private bool _shopEditSuccess; + // Revenue report state + private string _reportPeriod = "daily"; + private List _revenueReport = new(); protected override async Task OnInitializedAsync() => await LoadData(); @@ -1609,6 +1793,7 @@ case "menu": case "products": _products = await DataService.GetAllProductsAsync(_shopGuid); + _categories = await DataService.GetAllCategoriesAsync(_shopGuid); break; case "inventory": _inventory = await DataService.GetInventoryAsync(_shopGuid); @@ -1864,4 +2049,39 @@ 0 => "CN", 1 => "T2", 2 => "T3", 3 => "T4", 4 => "T5", 5 => "T6", 6 => "T7", _ => $"#{dow}" }; + + // ═══ CATEGORY CRUD ═══ + private async Task SaveCategory() + { + if (string.IsNullOrWhiteSpace(_newCategoryName)) { _categoryFormMessage = "Tên danh mục không được trống."; _categoryFormSuccess = false; return; } + var req = new PosDataService.AdminCreateCategoryRequest(_shopGuid ?? Guid.Empty, _newCategoryName, _newCategoryDesc, _newCategoryOrder); + bool ok; + if (_editingCategoryId.HasValue) + ok = await DataService.UpdateCategoryAsync(_editingCategoryId.Value, req); + else + ok = await DataService.CreateCategoryAsync(req); + _categoryFormMessage = ok ? (_editingCategoryId.HasValue ? "Đã cập nhật danh mục!" : "Đã thêm danh mục!") : "Lỗi khi lưu danh mục."; + _categoryFormSuccess = ok; + if (ok) { _showCategoryForm = false; _categories = await DataService.GetAllCategoriesAsync(_shopGuid); } + } + private void EditCategory(PosDataService.AdminCategoryInfo c) { _editingCategoryId = c.Id; _newCategoryName = c.Name ?? ""; _newCategoryDesc = c.Description ?? ""; _newCategoryOrder = c.DisplayOrder; _showCategoryForm = true; _categoryFormMessage = null; } + private async Task DeleteCategoryItem(Guid id) { await DataService.DeleteCategoryAsync(id); _categories = await DataService.GetAllCategoriesAsync(_shopGuid); } + + // ═══ ORDER DETAIL ═══ + private async Task ViewOrderDetail(Guid orderId) { if (_selectedOrderId == orderId) { _selectedOrderId = null; _orderDetail = null; return; } _selectedOrderId = orderId; _orderDetail = await DataService.GetOrderDetailAsync(orderId); } + private async Task CancelOrderItem(Guid orderId) { var ok = await DataService.CancelOrderAsync(orderId); if (ok) { _selectedOrderId = null; _orderDetail = null; await LoadData(); } } + + // ═══ SHOP EDIT ═══ + private void StartEditShop() { _editingShop = true; _shopEditName = _shopName ?? ""; _shopEditPhone = ""; _shopEditEmail = ""; _shopEditDesc = ""; _shopEditOpenTime = ""; _shopEditCloseTime = ""; _shopEditMessage = null; } + private async Task SaveShopEdit() + { + var req = new PosDataService.UpdateShopRequest(_shopEditName, _shopEditPhone, _shopEditEmail, _shopEditDesc, _shopEditOpenTime, _shopEditCloseTime, null); + var ok = await DataService.UpdateShopAsync(_shopGuid!.Value, req); + _shopEditMessage = ok ? "Đã cập nhật cửa hàng!" : "Lỗi khi cập nhật."; + _shopEditSuccess = ok; + if (ok) { _editingShop = false; await LoadData(); } + } + + // ═══ REVENUE REPORT ═══ + private async Task LoadRevenueReport(string period) { _reportPeriod = period; _revenueReport = await DataService.GetRevenueReportAsync(period, _shopGuid); } } 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 bab71daf..8fcd602b 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 @@ -299,4 +299,82 @@ public class PosDataService return await resp.Content.ReadFromJsonAsync(_jsonOptions); return null; } + + // ═══ CATEGORIES CRUD ═══ + + // EN: Category create/update request DTO + // VI: DTO tạo/cập nhật danh mục + public record AdminCreateCategoryRequest(Guid ShopId, string Name, string? Description, int DisplayOrder); + + public async Task CreateCategoryAsync(AdminCreateCategoryRequest req) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync("api/bff/categories", req, _jsonOptions); + return resp.IsSuccessStatusCode; + } + + public async Task UpdateCategoryAsync(Guid categoryId, AdminCreateCategoryRequest req) + { + AttachToken(); + var resp = await _http.PutAsJsonAsync($"api/bff/categories/{categoryId}", req, _jsonOptions); + return resp.IsSuccessStatusCode; + } + + public async Task DeleteCategoryAsync(Guid categoryId) + { + AttachToken(); + var resp = await _http.DeleteAsync($"api/bff/categories/{categoryId}"); + return resp.IsSuccessStatusCode; + } + + // ═══ ORDER DETAIL & CANCEL ═══ + + // EN: Order detail DTOs + // VI: DTOs cho chi tiết đơn hàng + public record OrderDetailInfo(Guid Id, Guid ShopId, decimal TotalAmount, string? Status, int StatusId, string? PaymentMethod, string? Notes, DateTime CreatedAt); + public record OrderItemInfo(Guid Id, string? ProductName, int Quantity, decimal UnitPrice, decimal Subtotal); + public record OrderDetailResponse(OrderDetailInfo? Order, List? Items); + + public async Task GetOrderDetailAsync(Guid orderId) + { + AttachToken(); + return await _http.GetFromJsonAsync($"api/bff/orders/{orderId}", _jsonOptions); + } + + public async Task CancelOrderAsync(Guid orderId) + { + AttachToken(); + using var req = new HttpRequestMessage(HttpMethod.Put, $"api/bff/orders/{orderId}/cancel"); + req.Headers.Authorization = _http.DefaultRequestHeaders.Authorization; + var resp = await _http.SendAsync(req); + return resp.IsSuccessStatusCode; + } + + // ═══ SHOP UPDATE ═══ + + // EN: Shop update DTO + // VI: DTO cập nhật thông tin cửa hàng + public record UpdateShopRequest(string? Name, string? Phone, string? Email, string? Description, string? OpenTime, string? CloseTime, string? OpenDays); + + public async Task UpdateShopAsync(Guid shopId, UpdateShopRequest req) + { + AttachToken(); + var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}", req, _jsonOptions); + return resp.IsSuccessStatusCode; + } + + // ═══ REVENUE REPORT ═══ + + // EN: Revenue report item DTO + // VI: DTO cho từng dòng báo cáo doanh thu + public record RevenueReportItem(DateTime Period, long OrderCount, decimal Revenue); + + public async Task> GetRevenueReportAsync(string period = "daily", Guid? shopId = null) + { + AttachToken(); + var url = shopId.HasValue + ? $"api/bff/reports/revenue?period={period}&shopId={shopId}" + : $"api/bff/reports/revenue?period={period}"; + return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new(); + } } 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 cee5f823..fa1929d1 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 @@ -1095,10 +1095,197 @@ public class BffDataController : ControllerBase } } + // ═══ CATEGORIES CRUD ═══ + + /// + /// EN: Create a category — validates shop ownership. + /// VI: Tạo danh mục — kiểm tra quyền sở hữu shop. + /// + [HttpPost("categories")] + public async Task CreateCategory([FromBody] CreateCategoryRequest req) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Forbid(); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Contains(req.ShopId)) + return Forbid(); + + var id = Guid.NewGuid(); + await using var conn = new NpgsqlConnection(ConnStr("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 }); + } + + /// + /// EN: Update a category — validates shop ownership. + /// VI: Cập nhật danh mục — kiểm tra quyền sở hữu shop. + /// + [HttpPut("categories/{categoryId:guid}")] + public async Task UpdateCategory(Guid categoryId, [FromBody] CreateCategoryRequest req) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + await using var conn = new NpgsqlConnection(ConnStr("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(); + } + + /// + /// 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. + /// + [HttpDelete("categories/{categoryId:guid}")] + public async Task DeleteCategory(Guid categoryId) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Forbid(); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + await using var conn = new NpgsqlConnection(ConnStr("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(); + } + + // ═══ ORDER DETAIL & CANCEL ═══ + + /// + /// 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. + /// + [HttpGet("orders/{orderId:guid}")] + public async Task GetOrderDetail(Guid orderId) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + await using var conn = new NpgsqlConnection(ConnStr("order_service")); + var order = await conn.QueryFirstOrDefaultAsync( + @"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( + @"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 }); + } + + /// + /// 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. + /// + [HttpPut("orders/{orderId:guid}/cancel")] + public async Task CancelOrder(Guid orderId) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + await using var conn = new NpgsqlConnection(ConnStr("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." }); + } + + // ═══ SHOP UPDATE ═══ + + /// + /// 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. + /// + [HttpPut("shops/{shopId:guid}")] + public async Task UpdateShop(Guid shopId, [FromBody] UpdateShopRequest req) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Unauthorized(); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Contains(shopId)) + return Forbid(); + + await using var conn = new NpgsqlConnection(ConnStr("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(); + } + + // ═══ REVENUE REPORT ═══ + + /// + /// 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. + /// + [HttpGet("reports/revenue")] + public async Task GetRevenueReport( + [FromQuery] string period = "daily", + [FromQuery] Guid? shopId = null) + { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) return Ok(Array.Empty()); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Any()) return Ok(Array.Empty()); + + if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) + return Ok(Array.Empty()); + + var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; + + await using var conn = new NpgsqlConnection(ConnStr("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(sql, new { ShopIds = targetShopIds.ToArray() }); + return Ok(report); + } + catch { return Ok(Array.Empty()); } + } + // 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); public record UpdateInventoryRequest(int Quantity, int ReorderLevel); + public record CreateCategoryRequest(Guid ShopId, string Name, string? Description, int DisplayOrder); + public record UpdateShopRequest(string? Name, string? Phone, string? Email, string? Description, string? OpenTime, string? CloseTime, string? OpenDays); }