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 0fe33a2a..5f7369a7 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 @@ -290,6 +290,7 @@
+ @order.ItemCount món
@FormatPrice(order.TotalAmount)
@order.CreatedAt.ToString("HH:mm dd/MM")
@@ -400,7 +401,7 @@ {
- @h.Hour + @h.HourLabel
}
@@ -782,13 +783,26 @@ try { var data = await DataService.GetPosDashboardAsync(ShopId); - // EN: Null-coalesce all lists to prevent NRE if API returns null - // VI: Null-coalesce tất cả list để tránh NRE nếu API trả null + var payments = data.PaymentBreakdown ?? new(); + var hourly = data.HourlyRevenue ?? new(); + + // EN: Compute percentage for payment breakdown + // VI: Tính phần trăm cho breakdown thanh toán + var totalPayments = payments.Sum(p => p.Amount); + foreach (var p in payments) + p.Pct = totalPayments > 0 ? (int)(p.Amount / totalPayments * 100) : 0; + + // EN: Compute percentage for hourly revenue (relative to max) + // VI: Tính phần trăm cho doanh thu theo giờ (tương đối so với max) + var maxHourlyRevenue = hourly.Any() ? hourly.Max(h => h.Revenue) : 0; + foreach (var h in hourly) + h.Pct = maxHourlyRevenue > 0 ? (int)(h.Revenue / maxHourlyRevenue * 100) : 0; + _dashboard = new PosDataService.PosDashboardInfo( data.Revenue, data.OrderCount, data.ItemsSold, data.AvgOrderValue, data.PopularItems ?? new(), - data.PaymentBreakdown ?? new(), - data.HourlyRevenue ?? new(), + payments, + hourly, data.RecentOrders ?? new()); } catch 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 6aed87d1..cff73c6a 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 @@ -11,12 +11,11 @@ public class PosDataService { private readonly HttpClient _http; private readonly AuthStateService _authState; - // EN: Read options — case-insensitive to handle both snake_case and camelCase responses - // VI: Options đọc — không phân biệt hoa thường để xử lý cả snake_case và camelCase + // EN: Read options — case-insensitive to handle camelCase responses from BFF/microservices. + // VI: Options đọc — không phân biệt hoa thường để xử lý camelCase responses từ BFF/microservices. private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; @@ -252,7 +251,7 @@ public class PosDataService // ═══ ORDERS ═══ public record OrderInfo(Guid Id, Guid ShopId, decimal TotalAmount, int StatusId, DateTime CreatedAt, - string? Status, string? PaymentMethod, string? Notes); + string? Status, string? PaymentMethod, string? Notes, int ItemCount = 0); public async Task> GetOrdersAsync(Guid? shopId = null, string filter = "today") { @@ -381,18 +380,30 @@ public class PosDataService // ═══ POS DASHBOARD (real-time daily stats) ═══ - // EN: POS dashboard response DTOs - // VI: DTOs cho response dashboard POS + // EN: POS dashboard response DTOs — matching camelCase API response from OrderService + // VI: DTOs cho response dashboard POS — khớp camelCase API response từ OrderService public record PosDashboardInfo( decimal Revenue, int OrderCount, int ItemsSold, decimal AvgOrderValue, List PopularItems, List PaymentBreakdown, List HourlyRevenue, List RecentOrders); - public record PopularItemInfo(string Name, int Qty, decimal Revenue); - public record PaymentBreakdownInfo(string Method, decimal Amount, int Pct); - public record HourlyRevenueInfo(string Hour, decimal Revenue, int Pct); - public record RecentOrderInfo(string Id, decimal Total, string Time, string Status, string Method); + public record PopularItemInfo(Guid ProductId, string ProductName, int QuantitySold, decimal Revenue) + { + // EN: Aliases for Razor display / VI: Alias cho hiển thị Razor + public string Name => ProductName; + public int Qty => QuantitySold; + } + public record PaymentBreakdownInfo(string Method, int Count, decimal Amount) + { + public int Pct { get; set; } + } + public record HourlyRevenueInfo(int Hour, decimal Revenue, int OrderCount) + { + public string HourLabel => $"{Hour}h"; + public int Pct { get; set; } + } + public record RecentOrderInfo(Guid Id, decimal TotalAmount, string Status, int ItemCount, DateTime CreatedAt); public async Task GetPosDashboardAsync(Guid shopId) { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs index 1b1393ff..050e3375 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs @@ -13,10 +13,12 @@ namespace WebClientTpos.Server.Controllers; public class OrderController : ControllerBase { private readonly HttpClient _order; + private readonly HttpClient _merchant; public OrderController(IHttpClientFactory httpClientFactory) { _order = httpClientFactory.CreateClient("OrderService"); + _merchant = httpClientFactory.CreateClient("MerchantService"); } /// @@ -69,12 +71,109 @@ public class OrderController : ControllerBase } /// - /// EN: Create a POS order. - /// VI: Tạo đơn POS. + /// EN: Create a POS order. Enriches items with correct productType based on shop category. + /// VI: Tạo đơn POS. Bổ sung productType chính xác cho items dựa trên loại shop. /// [HttpPost("pos/orders")] - public Task CreatePosOrder([FromBody] JsonElement body) => - _order.PostAsJsonAsync("/api/v1/orders", body).ProxyAsync(); + public async Task CreatePosOrder([FromBody] JsonElement body) + { + // EN: Determine default productType from shop category + // VI: Xác định productType mặc định từ loại shop + var defaultProductType = "Physical"; + if (body.TryGetProperty("shopId", out var shopIdProp) && Guid.TryParse(shopIdProp.GetString(), out var shopId)) + { + try + { + var shopResponse = await _merchant.GetAsync($"/api/v1/shops/{shopId}"); + if (shopResponse.IsSuccessStatusCode) + { + var shopJson = await shopResponse.Content.ReadAsStringAsync(); + using var shopDoc = JsonDocument.Parse(shopJson); + if (shopDoc.RootElement.TryGetProperty("category", out var categoryProp)) + { + var category = categoryProp.GetString()?.ToLowerInvariant() ?? ""; + defaultProductType = category switch + { + // EN: Cafe/Restaurant/Karaoke → PreparedFood (FnbStrategy, no inventory check) + // VI: Cafe/Restaurant/Karaoke → PreparedFood (FnbStrategy, không kiểm kho) + "cafe" or "restaurant" or "karaoke" => "PreparedFood", + // EN: Spa/Beauty → PreparedFood for POS walk-in sales (no appointment metadata needed) + // Use "Service" only when item has appointment metadata (startTime, durationMinutes) + // VI: Spa/Beauty → PreparedFood cho POS bán trực tiếp (không cần metadata đặt lịch) + // Chỉ dùng "Service" khi item có metadata đặt lịch + "spa" or "beauty" => "PreparedFood", + _ => "Physical" + }; + } + } + } + catch { /* fallback to Physical */ } + } + + // EN: Rewrite items with correct productType based on shop category and item metadata + // VI: Ghi lại items với productType đúng dựa trên loại shop và metadata của item + using var doc = JsonDocument.Parse(body.GetRawText()); + var root = doc.RootElement; + var options = new JsonWriterOptions { Indented = false }; + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, options)) + { + writer.WriteStartObject(); + foreach (var prop in root.EnumerateObject()) + { + if (prop.Name == "items" && prop.Value.ValueKind == JsonValueKind.Array) + { + writer.WriteStartArray("items"); + foreach (var item in prop.Value.EnumerateArray()) + { + // EN: If item has appointment metadata, use "Service" type; otherwise use shop default + // VI: Nếu item có metadata đặt lịch, dùng "Service"; còn lại dùng default của shop + var itemType = defaultProductType; + if (item.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.String) + { + try + { + using var metaDoc = JsonDocument.Parse(meta.GetString()!); + if (metaDoc.RootElement.TryGetProperty("startTime", out _) && + metaDoc.RootElement.TryGetProperty("durationMinutes", out _)) + { + itemType = "Service"; + } + } + catch { /* not valid metadata JSON, use default */ } + } + + writer.WriteStartObject(); + foreach (var itemProp in item.EnumerateObject()) + { + if (itemProp.Name == "productType") + { + writer.WriteString("productType", itemType); + } + else + { + itemProp.WriteTo(writer); + } + } + if (!item.TryGetProperty("productType", out _)) + { + writer.WriteString("productType", itemType); + } + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + else + { + prop.WriteTo(writer); + } + } + writer.WriteEndObject(); + } + + var enrichedBody = JsonDocument.Parse(stream.ToArray()).RootElement; + return await _order.PostAsJsonAsync("/api/v1/orders", enrichedBody).ProxyAsync(); + } /// /// EN: Get POS dashboard data — daily revenue, order count, popular items. diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQueryHandler.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQueryHandler.cs index 300d02f0..9884fe9e 100644 --- a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQueryHandler.cs +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQueryHandler.cs @@ -5,6 +5,8 @@ using MediatR; using MerchantService.Domain.AggregatesModel.MerchantAggregate; using MerchantService.Domain.AggregatesModel.ShopAggregate; using MerchantService.Domain.Exceptions; +using MerchantService.Domain.SeedWork; +using static MerchantService.API.Application.Queries.Shops.EnumResolverHelper; namespace MerchantService.API.Application.Queries.Shops; @@ -51,9 +53,9 @@ public class GetMyShopsQueryHandler : IRequestHandler(s.TypeId, "Unknown"), + Category = ResolveEnumName(s.CategoryId, "Other"), + Status = ResolveEnumName(s.StatusId, "Unknown"), LogoUrl = s.LogoUrl, BranchCount = s.Branches?.Count ?? 0, CreatedAt = s.CreatedAt @@ -88,9 +90,9 @@ public class GetShopByIdQueryHandler : IRequestHandler(shop.TypeId, "Unknown"), + Category = ResolveEnumName(shop.CategoryId, "Other"), + Status = ResolveEnumName(shop.StatusId, "Unknown"), Description = shop.Description, LogoUrl = shop.LogoUrl, CoverImageUrl = shop.CoverImageUrl, @@ -164,9 +166,9 @@ public class GetShopBySlugQueryHandler : IRequestHandler(shopWithBranches.TypeId, "Unknown"), + Category = ResolveEnumName(shopWithBranches.CategoryId, "Other"), + Status = ResolveEnumName(shopWithBranches.StatusId, "Unknown"), Description = shopWithBranches.Description, LogoUrl = shopWithBranches.LogoUrl, CoverImageUrl = shopWithBranches.CoverImageUrl, @@ -203,3 +205,23 @@ public class GetShopBySlugQueryHandler : IRequestHandler +/// EN: Helper to resolve Enumeration names from integer IDs. +/// VI: Helper để phân giải tên Enumeration từ integer IDs. +/// +internal static class EnumResolverHelper +{ + public static string ResolveEnumName(int id, string fallback) where T : Enumeration + { + try + { + var item = Enumeration.FromValue(id); + return item?.Name ?? fallback; + } + catch + { + return fallback; + } + } +}