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 @@
{
}
@@ -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;
+ }
+ }
+}