refactor(merchant-service): standardize enumeration name resolution in shop queries using a new helper method.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user