refactor(merchant-service): standardize enumeration name resolution in shop queries using a new helper method.

This commit is contained in:
Ho Ngoc Hai
2026-03-04 16:11:55 +07:00
parent 028ef4c1cd
commit 65f3da53ae
4 changed files with 174 additions and 28 deletions

View File

@@ -290,6 +290,7 @@
</span>
</div>
<div class="pos-history__card-body">
<span class="pos-history__items-preview">@order.ItemCount món</span>
<div class="pos-history__card-meta">
<div class="pos-history__total">@FormatPrice(order.TotalAmount)</div>
<div class="pos-history__time">@order.CreatedAt.ToString("HH:mm dd/MM")</div>
@@ -400,7 +401,7 @@
{
<div class="pos-dashboard__hourly-bar">
<div class="pos-dashboard__hourly-fill" style="height:@(h.Pct)%;opacity:@(h.Pct > 0 ? 1 : 0.3);"></div>
<span class="pos-dashboard__hourly-label">@h.Hour</span>
<span class="pos-dashboard__hourly-label">@h.HourLabel</span>
</div>
}
</div>
@@ -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

View File

@@ -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<List<OrderInfo>> 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<PopularItemInfo> PopularItems,
List<PaymentBreakdownInfo> PaymentBreakdown,
List<HourlyRevenueInfo> HourlyRevenue,
List<RecentOrderInfo> 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<PosDashboardInfo> GetPosDashboardAsync(Guid shopId)
{

View File

@@ -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");
}
/// <summary>
@@ -69,12 +71,109 @@ public class OrderController : ControllerBase
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("pos/orders")]
public Task<IActionResult> CreatePosOrder([FromBody] JsonElement body) =>
_order.PostAsJsonAsync("/api/v1/orders", body).ProxyAsync();
public async Task<IActionResult> 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();
}
/// <summary>
/// EN: Get POS dashboard data — daily revenue, order count, popular items.

View File

@@ -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<GetMyShopsQuery, IReadOnly
Id = s.Id,
Name = s.Name,
Slug = s.Slug,
Type = s.Type?.Name ?? "Unknown",
Category = s.Category?.Name ?? "Other",
Status = s.Status?.Name ?? "Unknown",
Type = ResolveEnumName<ShopType>(s.TypeId, "Unknown"),
Category = ResolveEnumName<BusinessCategory>(s.CategoryId, "Other"),
Status = ResolveEnumName<ShopStatus>(s.StatusId, "Unknown"),
LogoUrl = s.LogoUrl,
BranchCount = s.Branches?.Count ?? 0,
CreatedAt = s.CreatedAt
@@ -88,9 +90,9 @@ public class GetShopByIdQueryHandler : IRequestHandler<GetShopByIdQuery, ShopDet
MerchantId = shop.MerchantId,
Name = shop.Name,
Slug = shop.Slug,
Type = shop.Type?.Name ?? "Unknown",
Category = shop.Category?.Name ?? "Other",
Status = shop.Status?.Name ?? "Unknown",
Type = ResolveEnumName<ShopType>(shop.TypeId, "Unknown"),
Category = ResolveEnumName<BusinessCategory>(shop.CategoryId, "Other"),
Status = ResolveEnumName<ShopStatus>(shop.StatusId, "Unknown"),
Description = shop.Description,
LogoUrl = shop.LogoUrl,
CoverImageUrl = shop.CoverImageUrl,
@@ -164,9 +166,9 @@ public class GetShopBySlugQueryHandler : IRequestHandler<GetShopBySlugQuery, Sho
MerchantId = shopWithBranches.MerchantId,
Name = shopWithBranches.Name,
Slug = shopWithBranches.Slug,
Type = shopWithBranches.Type.Name,
Category = shopWithBranches.Category.Name,
Status = shopWithBranches.Status.Name,
Type = ResolveEnumName<ShopType>(shopWithBranches.TypeId, "Unknown"),
Category = ResolveEnumName<BusinessCategory>(shopWithBranches.CategoryId, "Other"),
Status = ResolveEnumName<ShopStatus>(shopWithBranches.StatusId, "Unknown"),
Description = shopWithBranches.Description,
LogoUrl = shopWithBranches.LogoUrl,
CoverImageUrl = shopWithBranches.CoverImageUrl,
@@ -203,3 +205,23 @@ public class GetShopBySlugQueryHandler : IRequestHandler<GetShopBySlugQuery, Sho
} : null;
}
}
/// <summary>
/// EN: Helper to resolve Enumeration names from integer IDs.
/// VI: Helper để phân giải tên Enumeration từ integer IDs.
/// </summary>
internal static class EnumResolverHelper
{
public static string ResolveEnumName<T>(int id, string fallback) where T : Enumeration
{
try
{
var item = Enumeration.FromValue<T>(id);
return item?.Name ?? fallback;
}
catch
{
return fallback;
}
}
}